← Back

Phase 2 · In production · Verified against the live Doohlabs CMS

Doohlabs Ad Server

A web ad server that reads what is booked in the Doohlabs CMS and decides, on the placement’s own clock, which creative belongs on screen at any given moment — then serves it, counts viewable impressions and clicks, and reports proof-of-play back.

Source
5,730 lines
Dependencies
1
Runtime
Node on Vercel
Live CMS
Verified

What it proves

One placement, one creative, one clock

A physical DOOH screen shows the same thing to everyone standing in front of it. This ad server reproduces that on the web: at any given second a placement has exactly one correct creative, and every visitor loading the page during that second sees it. Page loads do not influence what is shown — they only reveal what is already scheduled.

That property is the whole point of the build. Everything else in the codebase exists to support it.

Visitor’s browser Ad server Doohlabs CMS GET /p/{placementId} Resolve the placement which CMS, whose credential — cached 60s booking schedule only on cache miss — cached per instance, one call per burst Slot engine which creative, at this exact second HTML — image, embedded state, the loop inlined GET /creative/{id}?pl={placementId} download bytes only if not held at the edge or in memory — an hour in production GET /px — once viewable for 1s GET /click/{placementId}?b=… on a click counted, then 302 to the booking’s click URL, or the placement’s link the loop re-fetches this document when the slot ends no server push, no polling — a timer set to the exact millisecond
How one ad view works. The server never pushes an update. It tells the page how long the current slot has left, and the page comes back on its own at the boundary. That is what keeps every viewer in step without any connection held open.

Provisioning

Where a placement comes from

The diagram above starts at GET /p/{placementId} and takes that identifier for granted. It has to come from somewhere, and for the demo build it came from a map written into the source: two identifiers, one customer, and a redeploy for every addition. That does not survive a second customer.

A placement is born in the CMS. When one is created there, the CMS calls the ad server once, handing over its own address, the external player the placement belongs to, credentials to read that CMS with, and optionally where a click should go when the booking on screen has no click URL of its own. It gets back an opaque identifier and the embed code to show the user.

Doohlabs CMS Ad server Registry POST /provision cms_base_url · player_id · credentials · link_url seal the credential AES-GCM, key never stored with it write, keyed by a new UUID placement_id + embed code the identifier is opaque — it reveals no customer, and never changes
Registering a placement. The call happens once, when the placement is created. Everything afterwards — serving, counting, reporting — resolves that one identifier back to a CMS and a credential.

Two identifiers, and why both exist

A placement carries two UUIDs, and they belong to different systems. The player id is the CMS’s: it is what bookings are made against, and what proof-of-play is reported against. The placement id is this server’s, minted at provisioning. One is the key; the other is part of what it unlocks.

Placement idPlayer id
Owned bythe ad serverthe Doohlabs CMS
Createdby the registry, at provisioningin the CMS, with the player
Means to the CMSnothing — it is never sent therethe thing bookings hang off
Appears inthe embed code a publisher pastesthe served document, and every proof-of-play record

The indirection is not ceremony. An embed code lives on a publisher’s page indefinitely, so putting the CMS’s own player id there would hard-wire a third party’s identifier into pages nobody here controls — and if the CMS ever re-keyed its players, every live banner would break at once. A placement id cannot be invalidated by anything happening inside the CMS. It also reveals nothing: a random UUID in a public URL tells a competitor reading page source neither the customer nor the CMS behind it.

And one placement is one player today. Because the embed code carries only the placement id, that can change — a placement resolving to several players, or a player serving several placements — without reissuing a single tag.

Worth being exact about what is hidden: the player id does appear inside the served document, because the impression pixel carries it so a delivery knows what to report against. It is an identifier, not a credential. What never leaves the server is the CMS address and the credential the placement id unlocks.

A third identifier lives only in the browser: a page-view id, minted once when the document loads and never stored anywhere. The first counted pixel on a page — sequence 0is the page view. Every pixel after it on the same page, sequence ≥ 1, is a refresh impression: the same visitor, the same page view, a later creative. Both are impressions in the count that reaches the CMS; the distinction is what lets a page view be reported separately from how many times it refreshed.

Three properties are doing the work here.

  • The identifier is opaque and permanent. Embed codes are pasted into publisher pages and stay there indefinitely, so the identifier can never be recomputed or reissued. It is a random UUID rather than anything derived from the customer, because it travels in a public iframe URL where a readable name would leak who is running which campaign.
  • Registering twice is safe. The same CMS and player always return the same identifier, so a retried call cannot mint a duplicate — and rotating a credential means calling again with the new one, which updates it in place while every live embed code keeps working.
  • Credentials are encrypted before they are stored. The registry holds a sealed blob; the key lives in the environment, apart from the data it opens. Nothing reads a credential back out except the flush that needs it to reach that customer’s CMS.

The endpoint is authenticated, which is a deliberate departure from the reporting routes below. Those were opened on purpose because nothing reachable through them can inject data. This one creates records holding customer credentials, so the same argument does not carry — and with no secret configured it refuses to run rather than defaulting to open.


The decision

How two bookings share one placement

Each booked row carries a play_amount over a time window. Dividing one by the other gives a weight — and in the live CMS every row works out to exactly 72 plays per hour, whether its window is two hours or seventeen. Equal weights mean an equal share, so the two bookings alternate.

Each booking then cycles its own creatives independently, on its own turns.

Booking 943 1224 / 17h = 72/h Booking 944 144 / 2h = 72/h On screen 30s windows 9435 944100 94380 94484 9435 944100 94380 94484 9435 944100 94380 94484 12:00:00 12:02:30 12:05:00 Equal weight → equal share. The numbers below each booking are its creative, cycling on its own turns. A booking weighing three times another would win three slots to its one, interleaved rather than in a block.
Share of voice, resolved from the clock alone. This exact sequence — 943/5, 944/100, 943/80, 944/84 — is what the live system produced when sampled across five real slot boundaries. Nothing is stored between requests: the same second always yields the same answer.

Why speed never comes from a booking

play_amount decides share, never speed. How long a creative stays comes from two other places — the CMS’s own duration for that content and the placement’s refresh floor (next section) — and a booking’s goal has no say in it. Were cadence derived from the goal, a web-scale goal of 100,000 would produce a sub-second cycle. Keeping the two independent is what lets one engine serve a placement booked in screen-scale or web-scale numbers alike.

How long a creative stays, and when it counts

Each creative stays on screen for the CMS’s own duration, or the placement’s refresh floor, whichever is longer — a booking whose content is shorter than the floor is stretched to fill it rather than cycling faster than the floor allows. The floor defaults to 30 seconds (the IAB rotation minimum) and is set per placement at provisioning; a placement can also be set to off, which shows one creative for the whole page view and never re-evaluates.

An impression counts once at least half the creative has been continuously visible for one second, with the browser tab itself visible — the MRC viewable-impression rule. The first creative served and every refresh after it are counted the same way; a creative that rotates away before it clears the one-second bar counts nothing.

Two honest limits of that rule. Visibility is measured by the browser’s IntersectionObserver, which reports geometry, not paint: a creative covered by a cookie banner or a sticky header still counts as in view, which is where this differs from a fully audited MRC measurement. And the rule lives in the script, so with JavaScript disabled the creative is shown but nothing is counted and nothing rotates — the deliberate reading, since an impression nobody could verify was seen should not be counted.


Code map

What is in the repository

Every source file under src/ with more than a few lines, and the one file outside it. The engine is the only one that matters conceptually — the rest is scaffolding around it. Paths are relative to src/; the line counts are checked against the repository on every test run, so they are current rather than estimated.

FileLinesWhat it does
lib/slot-engine.ts368Decides what is on screen. Pure — no framework, no network, no stored state.
lib/cms.ts328Doohlabs CMS client, plus the one place the API’s inconsistent types are normalised.
routes/inspect.ts316Inspector: live bookings, the next ten slots, this placement’s measurements.
lib/creative-store.ts204Creative delivery, behind an interface a CDN can replace later.
lib/time.ts174Placement-local time and daylight saving, with no date library.
lib/reporting.ts156Turns one impression into one proof-of-play record.
lib/report-flush.ts351Delivers them, and decides what happens when delivery fails.
lib/types.ts144Types built from the real API responses, not the outdated spec.
lib/measurement-store.ts277Upstash keys: the queue per placement, the tallies (impressions, clicks) rolled up at acknowledge, the lock, the last outcome, the alert state.
routes/serve.ts140The document that goes in the publisher’s iframe.
routes/placement-client.ts151The loop that runs in the iframe, as a string: viewability, boundaries, off mode.
routes/demo.ts284The placements table — what the registry holds, and what each id resolves to.
lib/context.ts105Wires config, clock, CMS client and stores together per placement.
routes/px.ts117The 1×1 impression pixel.
lib/registry.ts339Placement records: mint, look up, update the credential, refresh policy or link, list.
lib/flush-runner.ts146Runs a delivery, and resolves each placement once per flush.
routes/provision.ts181The endpoint the CMS calls to register a placement.
routes/register-placement.ts538Registering a placement from a browser, and the API behind it.
lib/kv.ts + lib/secret-box.ts295The store, and the sealing that keeps credentials out of it in the clear.
routes/report.ts279The proof-of-play status page, and the endpoint that delivers on demand.
lib/base64.ts + lib/digest.ts + lib/urls.ts119Bytes, hashes and the one definition of where a placement lives.
routes/placement.ts66Resolves an id and serves the document, negotiated by Accept.
config/…66Environment settings.
routes/creative.ts76Serves creative bytes.
routes/click.ts84Counts a click and redirects to the booking’s click URL, or the placement’s link.
lib/alerting.ts219One message when delivery has been failing for a while, one when it recovers — to a webhook, or to Slack.
index.ts + lib/html.ts145Route registration and HTML escaping.
api/index.ts6The only file that knows it is running on Vercel.

Structure

What is tied to what

The scheduling logic has no idea it is inside a web framework, and no idea which hosting platform it is on. That is not a stylistic preference — it is what makes the platform choice reversible while the product is still young.

Reading order for someone new to the code: api/index.ts hands every request to the Hono app in src/index.ts, which mounts one file per route under src/routes/; the routes call into src/lib/, which has no framework imports; and tests/ mirrors both, one file per module, running the engine, the routes and the in-page loop entirely offline against an in-memory Upstash and a fixture CMS.

LayerWhereKnows about
Platform entryapi/index.tsVercel, and nothing else
Routesindex.ts, routes/*HTTP, HTML, the Hono app
Librarylib/*, config/*the CMS, the clock, the stores, the engine — no framework
Teststests/, fixtures/everything above, offline
api/index.ts — 6 lines the only Vercel-specific code Routing layer — Hono placement · click · creative · px · provision · register · report · inspect · demo Library layer — no framework imports time · cms · registry · kv · measurement-store · report-flush · alerting Slot engine schedule + a timestamp in, decision out no network, no clock of its own, no stored state 43 tests replace one file Cloudflare Workers everything else unchanged
Nothing in the library layer imports the web framework — verified, not asserted. Moving to another edge platform means rewriting the six-line entry file; the engine, the clock handling and the CMS client travel untouched.

Status

Where it stands today

Phase 1 and the Phase 2 items are built and running in production. Every row below has been exercised against the real Doohlabs CMS rather than test data; the one not marked verified waits on a destination for its messages.

CapabilityStateEvidence
Reads live bookings from the CMSVerifiedBoth placements, real credentials, 5 and 6 booked rows
Divides time between bookingsVerifiedEvery live row computes to exactly 72/h, as predicted
Placement-local time, incl. daylight savingVerifiedTested on both sides of the October transition
Same creative for every viewerVerifiedSampled across five real slot boundaries
Serves real creativesVerifiedAll four downloaded with correct types
Counts impressionsVerifiedPer placement, matched against what is on screen
Proof-of-play reportingVerifiedDelivers itself; batches accepted by the demo CMS, nothing leaves the queue unaccepted
Goal sizing for a production bookingAnsweredGoals do not apply to web placements — confirmed for production
Placements registered through the CMSVerifiedBoth demo placements provisioned into the live registry; re-registering returns the same id
Credentials encrypted at restVerifiedSealed before the store sees them; opened only to reach that customer’s CMS
In productionVerifiedVercel Pro plan, the minute cron delivering; every route exercised on the live deployment
Which runtime it lands onAnsweredNode, not Edge — /health reports it, and Edge cannot be pinned without hanging every request
Creatives served from the edgeVerifiedx-vercel-cache: HIT after the first request per region; a replaced creative is live within two intervals, one hour each in production
Shared measurement storeVerifiedQueue, tallies and alert state in Upstash; a restart loses nothing queued
Viewable impressions and per-placement refreshVerifiedHalf in view for one second, tab visible; 30-second floor, or off
Clicks counted and redirectedVerifiedTo the booking’s click URL from the CMS, or the placement’s link; CTR on the reporting page
Delivery-failure alertingBuiltThree failing runs send one message, recovery sends one; the destination URL is still to be chosen
Custom domainVerifiedadserver.doohlabs.com serves production with an automatically issued certificate

A blank placement is usually a correct one

When nothing is booked for the current moment the placement shows nothing, and counts nothing. The demo CMS carries short booking windows, so before showing the system, check that a booking covers the time — the inspector at /inspect/<placementId> says what is active now and what comes next.


Reporting

Where an impression goes

Every counted view fires a pixel onto a shared queue in Upstash. The queue depth shows on the inspector immediately as awaiting delivery; the settled figures follow once a cron flush delivers the batch to the Doohlabs CMS — one request per placement, holding anything the CMS has not yet accepted.

Pixel fires one per counted impression Queued in Upstash shared, queued until accepted Placement inspector live counts, last flush Flush endpoint cron, or by hand resolved through the registry Proof-of-play record player_id, start, duration, campaign_id, content_id one per impression Doohlabs CMS this placement’s own CMS open, no credential REPORTING_ENABLED only once accepted is a record removed from the queue — a failed request retries What reporting against a live customer booking rests on Counting is settled: one view, one count — no crowd multiplier is applied. Goals do not apply to web placements, so a screen-scale goal cannot be exhausted.
An impression now reaches the CMS — carefully. Nothing leaves the queue until the CMS has accepted it, so a failed request retries rather than losing the record. An impression that cannot be attributed to a campaign is dropped rather than guessed at: a wrong row mis-paces a real booking silently, which is worse than a row that never arrives.

Cron-driven, and an open door

Delivery is driven by a Vercel Cron Job calling the flush endpoint every minute, not by the pixel — there is no pixel-side trigger any more. REPORTING_ENABLED decides only whether a batch actually reaches the CMS: with it off the cron still runs but withholds every batch, leaving it queued.

The reporting routes are open. They were behind a credential; the browser prompt that produced stood between a person and the status page, so it was removed. On a public deployment that means anyone with the URL can read a placement’s traffic and trigger a delivery. What they cannot do is inject anything: a flush only delivers impressions that were counted by a served document, and one that cannot be attributed to a live booking is dropped rather than guessed at.

A status page anyone can open shows what the last delivery did, so “is it working?” is a question with an answer rather than an inference.

What one impression means on the web

On a physical screen the multiplier is greater than one: a single play reaches everyone walking past. On the web the equivalent factor works the other way — it is viewability, a discount rather than an amplifier. Doohlabs have confirmed the rule: one view, one count. Display cadence and reporting stay independent, so a creative on screen for ten seconds may be seen by fifty people, and both numbers are correct.

How much of the truth this reports

The queue and the tallies live in Upstash, shared across every server instance, not held in one instance’s memory — a delivery carries what the whole deployment counted, and a restart loses nothing still queued. Tallies settle when a batch is acknowledged, so the settled figures can lag the queue by up to one cron interval; awaiting delivery is shown beside them so that lag is visible rather than looking like traffic went quiet. One caveat remains: a flusher that dies between the CMS’s accept and the trim sends that batch again, tallied once. Clicks take the other path: counted into the tally the moment they happen, and not delivered anywhere, because the CMS has no endpoint for them yet.


Deliberately absent

What it does not do yet

These are recorded rather than overlooked. Building them now would have added things that can break during a demo while proving nothing extra.

  • No versioned delivery yet. Creatives are fetched from the CMS, held in memory, and cached at Vercel’s edge, both for the same configurable interval (an hour in production); a creative replaced in the CMS under the same id is live within two intervals, worst case, and the interval is a setting rather than a deploy. Versioned, year-long-cacheable URLs would let the edge hold bytes indefinitely; they are the upgrade if traffic ever makes the re-reads matter. The live creatives run from 506 KB to 2.7 MB — against a typical web banner budget of 150–200 KB, which is a strong argument for resizing them on ingest.
  • No click reporting to the CMS. A click is counted here and redirected to the booking’s click URL from the CMS, or to the placement’s own link when the booking has none, and shows on the reporting page as a count and a click-through rate. The CMS has no endpoint to receive clicks yet, so that is where it stops: when one lands, the count travels with proof-of-play. The tag does not change.
  • No user-initiated refresh. The ad runs in an iframe on the ad server’s origin, and a cross-origin iframe cannot see the publisher’s page — a reader scrolling or clicking the article is invisible to it. Refreshing on those signals, as some publishers do, needs a small script on the publisher’s side that tells the iframe; it is deferred until a customer asks for it. What the iframe can see is its own visibility, which is what the refresh floor and the one-second rule are built on.