JavaScript API
The routingcat package, which npx routingcat init installs, gives your Astro site:
routingcat/astro, the integration inastro.config, and the declarations;routingcat/astro/components,<RoutingCat />for your layout and<RoutingCatSlot>for your pages;routingcat/tracker,routingcat, whose calls report conversions from your pages’ own scripts, androutingcatPropertiesfor your PostHoginit;routingcat/vercelorroutingcat/cloudflare, the entry that runs on your host: Vercel’s middleware, or the Worker on Cloudflare Workers;routingcat/browser, for other frontends, such as an app on another of your domains, also as a script tag.
routingcat/astro
routingcat()
routingcat(options?: RoutingCatAstroOptions): AstroIntegrationThe integration, the entry’s default export, in astro.config‘s integrations, as init adds it. At each astro build, it registers what routingcat/ declares, and gives your host’s entry the build’s values. Its middleware runs before yours, and hands a page rendered at each request the variants RoutingCat picked for its visitor. Under astro dev, ?rc= forces variants, and nothing is recorded. It runs on Vercel, with @astrojs/vercel or no adapter, and on Cloudflare Workers, with @astrojs/cloudflare, and matches your pages’ routes from your site’s root, so your site is served from /, with no base of its own; the build says when either is not so. From the example app, astro.config.mjs:
// The example's Astro config (README.md): routingcat/astro's integration, and the host's adapter,// Vercel's when Vercel builds it, Cloudflare's otherwise, whose Worker is the custom entrypoint// src/worker.ts (wrangler.jsonc). /pricing renders at each request; every other page is// prerendered. No session and no image binding, which the example needs not.import cloudflare from '@astrojs/cloudflare'import vercel from '@astrojs/vercel'import { defineConfig } from 'astro/config'import routingcat from 'routingcat/astro'
export default defineConfig({ integrations: [routingcat()], adapter: process.env.VERCEL ? vercel() : cloudflare({ imageService: 'passthrough' }), session: false,})Its options:
| Option | Type | Default | What it does |
|---|---|---|---|
posthog |
string |
None | The module whose default export is your site’s PostHog client, which RoutingCat registers the visitor id and the variants with, for a site whose client is not posthog-js’s own instance: a path from your project’s root, as './src/lib/posthog.ts'. Unset, posthog-js’s own instance, where your project depends on posthog-js; else window.posthog. See PostHog. |
Astro.locals.routingcat.track()
Astro.locals.routingcat.track(name: string, options: TrackOptions & { readonly id: string }): Promise<void>Reports an event, such as a conversion, from a page or an endpoint rendered at each request: RoutingCat’s middleware gives each request Astro.locals.routingcat, which an endpoint reads as locals.routingcat. Call it without await: it sends within the request’s lifetime, so a page is never slower, and never throws, so it never breaks a sign-up. A prerendered page renders once, for every visitor, and sends nothing. From the example app:
import type { APIRoute } from 'astro'
export const prerender = false
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
export const POST: APIRoute = async ({ request, locals }) => { const body = (await request.json().catch(() => null)) as { email?: unknown } | null const email = String(body?.email ?? '').trim() if (!EMAIL.test(email)) { return Response.json({ ok: false, message: 'Enter a valid email address.' }, { status: 400 }) } const digest = await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(email.toLowerCase()), ) const id = Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('') locals.routingcat.track('waitlist', { id: `waitlist:${id}` }) return Response.json({ ok: true, message: 'You are on the list.' })}| Option | Type | What it does |
|---|---|---|
id |
string |
The dedup key: an event sent again with the same name and id counts once. Required on the server: the provider’s event id, or signup:<accountId>; a call without it is not sent, and says so once in your logs. Generated in the browser. |
occurredAt |
Date | string | number |
When it happened: a Date, an RFC 3339 string, or a number of Unix seconds, or of milliseconds as Date.now() gives them; the call’s time when absent. The browser always uses the time the event arrives. |
visitorId |
string |
The visitor, when the request is not theirs, as in a webhook: the rc_vid its checkout recorded. The request’s rc_vid cookie by default. |
accountId |
string |
Your app’s id for the account, when you have it: a record id, never an email address. |
value |
number |
A number. With currency, money in major units: 29.99 for $29.99. Without, a plain quantity. |
currency |
string |
The ISO 4217 code, in either case. |
properties |
object | An object of at most 8,000 bytes of JSON, recorded with the event. |
items |
LineItem[] |
Line items: { id, quantity?, unitValue }, in the event’s currency, quantity 1 when absent. |
Astro.locals.routingcat.identify()
Astro.locals.routingcat.identify(accountId: string, options?: IdentifyOptions): Promise<void>Links the visitor to your app’s account id, at sign-up and at every sign-in, so conversions on another device count. It never delays the response and never throws. Write await before it, as the examples do; RoutingCat joins a link and the events in whichever order they arrive. From the example app:
import type { APIRoute } from 'astro'
export const prerender = false
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
export const POST: APIRoute = async ({ request, locals }) => { const body = (await request.json().catch(() => null)) as { email?: unknown } | null const email = String(body?.email ?? '').trim() if (!EMAIL.test(email)) { return Response.json({ ok: false, message: 'Enter a valid email address.' }, { status: 400 }) } const account = `acct_${crypto.randomUUID()}` await locals.routingcat.identify(account) return Response.json({ ok: true, account })}| Option | Type | What it does |
|---|---|---|
visitorId |
string |
The visitor to link, when the request is not theirs, as in a webhook. The request’s rc_vid cookie by default. |
Astro.locals.routingcat.shown()
Astro.locals.routingcat.shown(page: P): Shown<P> | nullWhich variant each slot of a page shows, by slot key, for server code that must know, such as an analytics call: Astro.locals.routingcat.shown(pricing), typed from the page’s declaration. On a page rendered at each request, it reads the variants RoutingCat picked for the visitor. On a prerendered page, or one a cache serves every visitor, it is null: every variant is in the page’s HTML, and the head snippet shows each visitor theirs in the browser. It is null for another page’s declaration too.
config(), page(), slot() and variant()
The declarations: see Config and declarations.
Also exported
DeclarationError, what the declarations throw for a declaration that breaks a rule.- The type
RoutingCatAstroOptions, of the integration’s options. - The type
RoutingCatLocals, ofAstro.locals.routingcat, where RoutingCat’s middleware puts the calls above;Shown, whatshown()returns; andTrackOptionsandIdentifyOptions, of the calls’ options. - The types
ConfigDeclaration,PageDeclaration,SlotDeclarationandVariantDeclaration.
routingcat/astro/components
<RoutingCat />
In your layout’s <head>, first after the charset and viewport tags. It writes the head snippet, which shows each visitor’s variants before the page paints, then RoutingCat’s script, which reports what each slot showed and how it was seen, and shows the next page’s variants when Astro’s client router swaps it in. It renders nothing else. From the example app, src/layouts/Base.astro:
---// …import { ClientRouter } from 'astro:transitions'import { RoutingCat } from 'routingcat/astro/components'
interface Props { title: string}
const { title } = Astro.props---<html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <RoutingCat /> <title>{title}</title> <ClientRouter /> … </head> <body> <header> <nav> <a href="/">Home</a> <a href="/pricing">Pricing</a> <a href="/about">About</a> </nav> </header> <slot /> </body></html>| Prop | Type | Default | What it does |
|---|---|---|---|
nonce |
string |
None | Your page’s Content Security Policy nonce, where your policy allows scripts by nonce: the head snippet carries it, and so do the style rules it writes. |
<RoutingCatSlot>
Renders one slot of a page, as its page is rendered. On a prerendered page, it writes every variant, the default shown and the others hidden, and the head snippet shows each visitor’s variant; on a page rendered at each request, it writes the visitor’s variant alone. Its child is a function, which receives the variant’s fields, then its key, and returns what to show.
| Prop | Type | Default | What it does |
|---|---|---|---|
page |
PageDeclaration |
Required | The page’s declaration, which the page imports from routingcat/. |
name |
string |
Required | The slot’s key, one the page declares. It is name, never slot, which Astro reads as a layout’s own named slot, so a slot placed directly in a layout stays in the page. |
tag |
'div' | 'span' |
div |
span inside a paragraph, or anywhere else only inline content can go, where a div would close the paragraph. |
class |
string |
None | Classes for the element that holds the slot: around its variants, or the visitor’s variant on a page rendered at each request. |
id |
string |
None | An id for that element. |
From the example app, the prerendered src/pages/index.astro:
---// `/`, prerendered: every variant in the page, the head snippet showing the host's selection.import { RoutingCatSlot } from 'routingcat/astro/components'import { home } from '../../routingcat/home'import Base from '../layouts/Base.astro'---<Base title="RoutingCat"> <main> <RoutingCatSlot page={home} name="hero"> {(fields: { headline: string }) => <h1>{fields.headline}</h1>} </RoutingCatSlot> <RoutingCatSlot page={home} name="subhead"> {(fields: { text: string }) => <p>{fields.text}</p>} </RoutingCatSlot> <a href="/pricing">See pricing</a> </main></Base>And src/pages/pricing.astro, rendered at each request, with a slot inside a sentence:
---// `/pricing`, rendered at each request: the variants the host's decision selects, alone.import { RoutingCatSlot } from 'routingcat/astro/components'import { pricing } from '../../routingcat/pricing'import Base from '../layouts/Base.astro'
export const prerender = false---<Base title="Pricing"> <main> <h1>Pricing</h1> <RoutingCatSlot page={pricing} name="lead"> {(fields: { lead: string }) => <p class="lead">{fields.lead}</p>} </RoutingCatSlot> <p> Start your <RoutingCatSlot page={pricing} name="trial" tag="span"> {(fields: { days: number }) => `${fields.days}-day`} </RoutingCatSlot> trial. </p> </main></Base>The entry also exports the types of the components’ props, RoutingCatProps and RoutingCatSlotProps.
routingcat/tracker
routingcat.track() and routingcat.identify()
routingcat.track(name: string, options?: TrackOptions): Promise<void>routingcat.identify(accountId: string, options?: IdentifyOptions): Promise<void>The calls of your pages’ own scripts, which run in the browser, where there is no Astro.locals: import { routingcat } from 'routingcat/tracker' in a <script>. They report an event, such as a conversion, and link the visitor to your app’s account id, from the page, with your site’s public key, which the script of <RoutingCat /> brings to the page. Both never throw. An event’s time is when it arrives, and track’s options are:
| Option | Type | Default | What it does |
|---|---|---|---|
id |
string |
None | The dedup key: an event sent again with the same name and id counts once. Generated when absent. |
visitorId |
string |
The rc_vid cookie’s |
The visitor. |
accountId |
string |
None | Your app’s id for the account, when you have it: a record id, never an email address. |
value |
number |
None | A number. With currency, money in major units: 29.99 for $29.99. Without, a plain quantity. |
currency |
string |
None | The ISO 4217 code, in either case. |
properties |
object | None | An object of at most 8,000 bytes of JSON, recorded with the event. |
items |
LineItem[] |
None | Line items: { id, quantity?, unitValue }, in the event’s currency, quantity 1 when absent. |
routingcat.link()
routingcat.link(url: string | URL): stringA link to another of your domains, carrying the visitor id, so the visit continues there: the script on that domain adopts the id. Add the domain with npx routingcat domains add.
routingcatProperties
PostHog’s before_send hook, for your site’s PostHog init in a <script>: import { routingcatProperties } from 'routingcat/tracker', then before_send: routingcatProperties among posthog.init’s options. It puts the visitor id and the page’s variants on every PostHog event as it leaves, a page view’s first $pageview included.
routingcat/vercel
middleware()
middleware(handler: MiddlewareHandler | null, options: MiddlewareOptions): (request: Request, context?: MiddlewareContext) => Promise<Response>Vercel’s Routing Middleware, your root middleware.ts’s default export, on Node.js, as init writes it: middleware(null, { … }). It picks each visitor’s variants before Vercel serves the page, from its cache or not, and passes /_rc on to RoutingCat, so that the browser’s reports reach it through your own domain. Around a middleware of your own, middleware(yourMiddleware, { …, paths }), your code first, as in worker(app, …): your middleware runs first on your own paths.
From the example app:
// Vercel's root middleware for an Astro site, the file `init` is to write (integrations/a_architecture// §4.4, §5.5): RoutingCat decides every page before Vercel's cache, on Node.js, prerendered pages'// hits included, with the build, which `astro build` writes to .routingcat/ before Vercel builds// this file. Astro's own middleware keeps the adapter's default mode: deployed at the edge,// `middlewareMode: 'edge'`, it serves the pages rendered at request time past this file, which then// routes the prerendered pages alone, and the build warns with D117.import { ASTRO_DESCRIPTOR, middleware } from 'routingcat/vercel'import build from './.routingcat/values.mjs'
export const config = { runtime: 'nodejs', matcher: [ '/((?!_astro/|_image(?:/|$)|_server-islands/|_actions/|_render(?:/|$)|_isr(?:/|$)|_rc/|api/|.*\\.[a-z0-9]+$).*)', '/_rc/:path*', ], // RoutingCat: Astro's pages, and /_rc}
export default middleware(null, { framework: ASTRO_DESCRIPTOR, matcher: config.matcher, build,})| Option | Type | Default | What it does |
|---|---|---|---|
framework |
FrameworkDescriptor |
Required | What renders your pages, as init wrote it: its descriptor, whose contents are RoutingCat’s own. |
matcher |
string | string[] |
Required | config.matcher, as it is: the paths RoutingCat runs on, which the middleware reads as Vercel does, with the test of what is one of your pages. Around a middleware of your own, RoutingCat’s entries of it alone. |
build |
RoutingCatBuild |
Required | .routingcat/values.mjs’s default export, the build, which your build writes and the package types: its values, and the snapshot, the routing data a new instance decides with until its first refresh and while RoutingCat can’t be reached. Without values, pages serve their defaults and nothing is recorded. |
paths |
string[] |
None | Your own middleware’s matcher entries, unchanged, as data: your handler runs only on those paths. |
Also exported
ASTRO_DESCRIPTOR, the framework that renders an Astro site’s pages.- The types
MiddlewareOptions,MiddlewareHandler,MiddlewareContext,RoutingCatBuildandFrameworkDescriptor.
routingcat/cloudflare
worker()
worker<Env>(app: CloudflareWorker<Env> | null, options: WorkerOptions): CloudflareWorker<Env> & { fetch(request: Request, env: Env, ctx: WorkerContext): Promise<Response> }The Worker that picks each visitor’s variants before the page is served, your Worker’s default export, as init writes it, and that passes /_rc on to RoutingCat.
It stands in front of Astro’s custom entrypoint, which Astro’s Cloudflare adapter builds: worker(app, { mode: 'wrap', … }). From the example app:
// The Worker on Cloudflare Workers for an Astro site, the file `init` writes// (integrations/a_architecture §4.5, §5.5): Astro's custom entrypoint, cf(), astro() and// finalize(), wrapped in RoutingCat's, which decides each page first. The build comes from// routingcat/vite's module, which `astro build` builds into the Worker with the bundle compiled from// the build's push; under `astro dev`, which runs this Worker, the build says it is a development// server's, and `?rc=` forces variants. wrangler.jsonc names this file.
import build from 'virtual:routingcat/values'import { cf, finalize } from '@astrojs/cloudflare/fetch'import { astro, FetchState } from 'astro/fetch'import { ASTRO_DESCRIPTOR, type WorkerContext, worker } from 'routingcat/cloudflare'
const app = { // The Worker's bindings, as Astro's adapter reads them: ASSETS, CF_VERSION_METADATA. async fetch(request: Request, env: unknown, ctx: WorkerContext) { const state = new FetchState(request) const asset = await cf(state, env, ctx) if (asset) return asset return finalize(state, await astro(state)) },}
export default worker(app, { mode: 'wrap', framework: ASTRO_DESCRIPTOR, // The same literal as run_worker_first in the Wrangler configuration: Astro's page test leaves out // the server's own paths it runs first on. matcher: ['/*', '!/_astro/*'], build,})| Option | Type | Default | What it does |
|---|---|---|---|
mode |
'wrap' |
Required | In front of Astro’s own Worker, the first argument. |
framework |
FrameworkDescriptor |
Required | What renders your pages, as init wrote it: its descriptor, whose contents are RoutingCat’s own. |
matcher |
string | string[] |
Required | The paths RoutingCat runs on: the same patterns as run_worker_first in your Wrangler configuration, which the Worker reads as Cloudflare does, with the test of what is one of your pages, which those patterns can’t say. |
build |
RoutingCatBuild |
Required | virtual:routingcat/values’s default export, the build, which your build writes and the package types: its values, and the snapshot, the routing data a new instance decides with until its first refresh and while RoutingCat can’t be reached. Without values, pages serve their defaults and nothing is recorded. |
Also exported
ASTRO_DESCRIPTOR, the framework that renders an Astro site’s pages.- The types
WorkerOptions,CloudflareOptions,CloudflareWorker,WorkerContext,RoutingCatBuildandFrameworkDescriptor.
routingcat/browser
For a frontend that is not your Astro site, such as an app on another domain: it sends events with the site’s public key, through your Astro site’s /_rc path.
Its calls are its routingcat object’s, import { routingcat } from 'routingcat/browser', the same object its script tag puts on the page’s window.routingcat.
routingcat.init()
routingcat.init(key: string, options?: InitOptions | null): voidSets the site’s public key, from routingcat.config.ts, and where to send. Calls before it send nothing.
| Option | Type | What it does |
|---|---|---|
env |
'production' | 'preview' |
production, by default, or preview. |
collector |
string |
The site whose /_rc path to send through, such as https://lumen.so for an app on another domain. The page’s own by default. |
routingcat.track() and routingcat.identify()
routingcat.track(name: string, options?: TrackOptions): Promise<void>routingcat.identify(accountId: string, options?: IdentifyOptions): Promise<void>As your own app’s routingcat.track() and routingcat.identify() do, from the browser: an event’s id is generated where you give none, and its time is when it arrives. An option set to null reads as absent.
routingcat.link()
routingcat.link(url: string | URL): stringA link to another of your domains, carrying the visitor id, so the visit continues there: the script on that domain adopts the id. Add the domain with npx routingcat domains add.
The entry also exports the types InitOptions, TrackOptions, IdentifyOptions and LineItem.
routingcatProperties
PostHog’s before_send hook, as routingcat/tracker’s: in your PostHog init, before_send: routingcatProperties puts the visitor id and the page’s variants on every PostHog event.
The script tag
The same entry as a script, at a URL that never changes for a version, with its integrity hash:
<script src="https://cdn.routingcat.com/browser/1.0.3.js" integrity="sha384-ZnRbP0Groc12kRo2DHm+EpSi/r27mWVkwT7ehqGhXHZY3NjqdZvTJHtolZL8IbFW" crossorigin="anonymous" data-key="pk_…"></script>The copy of this page in node_modules/routingcat/docs/ gives the installed version’s hash. The script reads data-key, the site’s public key; data-env, preview for a preview build; and data-collector, as routingcat.init’s collector. It puts its calls on the page’s window.routingcat: routingcat.track, routingcat.identify, routingcat.link, routingcat.init and routingcat.routingcatProperties. Or copy node_modules/routingcat/dist/browser.global.js into your site and load it from there.