Phase 2 · In production · Verified against the live Doohlabs CMS
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.
What it proves
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.
Provisioning
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.
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 id | Player id | |
|---|---|---|
| Owned by | the ad server | the Doohlabs CMS |
| Created | by the registry, at provisioning | in the CMS, with the player |
| Means to the CMS | nothing — it is never sent there | the thing bookings hang off |
| Appears in | the embed code a publisher pastes | the 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 0 — is 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 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
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.
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.
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
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.
| File | Lines | What it does |
|---|---|---|
| lib/slot-engine.ts | 368 | Decides what is on screen. Pure — no framework, no network, no stored state. |
| lib/cms.ts | 328 | Doohlabs CMS client, plus the one place the API’s inconsistent types are normalised. |
| routes/inspect.ts | 316 | Inspector: live bookings, the next ten slots, this placement’s measurements. |
| lib/creative-store.ts | 204 | Creative delivery, behind an interface a CDN can replace later. |
| lib/time.ts | 174 | Placement-local time and daylight saving, with no date library. |
| lib/reporting.ts | 156 | Turns one impression into one proof-of-play record. |
| lib/report-flush.ts | 351 | Delivers them, and decides what happens when delivery fails. |
| lib/types.ts | 144 | Types built from the real API responses, not the outdated spec. |
| lib/measurement-store.ts | 277 | Upstash keys: the queue per placement, the tallies (impressions, clicks) rolled up at acknowledge, the lock, the last outcome, the alert state. |
| routes/serve.ts | 140 | The document that goes in the publisher’s iframe. |
| routes/placement-client.ts | 151 | The loop that runs in the iframe, as a string: viewability, boundaries, off mode. |
| routes/demo.ts | 284 | The placements table — what the registry holds, and what each id resolves to. |
| lib/context.ts | 105 | Wires config, clock, CMS client and stores together per placement. |
| routes/px.ts | 117 | The 1×1 impression pixel. |
| lib/registry.ts | 339 | Placement records: mint, look up, update the credential, refresh policy or link, list. |
| lib/flush-runner.ts | 146 | Runs a delivery, and resolves each placement once per flush. |
| routes/provision.ts | 181 | The endpoint the CMS calls to register a placement. |
| routes/register-placement.ts | 538 | Registering a placement from a browser, and the API behind it. |
| lib/kv.ts + lib/secret-box.ts | 295 | The store, and the sealing that keeps credentials out of it in the clear. |
| routes/report.ts | 279 | The proof-of-play status page, and the endpoint that delivers on demand. |
| lib/base64.ts + lib/digest.ts + lib/urls.ts | 119 | Bytes, hashes and the one definition of where a placement lives. |
| routes/placement.ts | 66 | Resolves an id and serves the document, negotiated by Accept. |
| config/… | 66 | Environment settings. |
| routes/creative.ts | 76 | Serves creative bytes. |
| routes/click.ts | 84 | Counts a click and redirects to the booking’s click URL, or the placement’s link. |
| lib/alerting.ts | 219 | One message when delivery has been failing for a while, one when it recovers — to a webhook, or to Slack. |
| index.ts + lib/html.ts | 145 | Route registration and HTML escaping. |
| api/index.ts | 6 | The only file that knows it is running on Vercel. |
Structure
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.
| Layer | Where | Knows about |
|---|---|---|
| Platform entry | api/index.ts | Vercel, and nothing else |
| Routes | index.ts, routes/* | HTTP, HTML, the Hono app |
| Library | lib/*, config/* | the CMS, the clock, the stores, the engine — no framework |
| Tests | tests/, fixtures/ | everything above, offline |
Status
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.
| Capability | State | Evidence |
|---|---|---|
| Reads live bookings from the CMS | Verified | Both placements, real credentials, 5 and 6 booked rows |
| Divides time between bookings | Verified | Every live row computes to exactly 72/h, as predicted |
| Placement-local time, incl. daylight saving | Verified | Tested on both sides of the October transition |
| Same creative for every viewer | Verified | Sampled across five real slot boundaries |
| Serves real creatives | Verified | All four downloaded with correct types |
| Counts impressions | Verified | Per placement, matched against what is on screen |
| Proof-of-play reporting | Verified | Delivers itself; batches accepted by the demo CMS, nothing leaves the queue unaccepted |
| Goal sizing for a production booking | Answered | Goals do not apply to web placements — confirmed for production |
| Placements registered through the CMS | Verified | Both demo placements provisioned into the live registry; re-registering returns the same id |
| Credentials encrypted at rest | Verified | Sealed before the store sees them; opened only to reach that customer’s CMS |
| In production | Verified | Vercel Pro plan, the minute cron delivering; every route exercised on the live deployment |
| Which runtime it lands on | Answered | Node, not Edge — /health reports it, and Edge cannot be pinned without hanging every request |
| Creatives served from the edge | Verified | x-vercel-cache: HIT after the first request per region; a replaced creative is live within two intervals, one hour each in production |
| Shared measurement store | Verified | Queue, tallies and alert state in Upstash; a restart loses nothing queued |
| Viewable impressions and per-placement refresh | Verified | Half in view for one second, tab visible; 30-second floor, or off |
| Clicks counted and redirected | Verified | To the booking’s click URL from the CMS, or the placement’s link; CTR on the reporting page |
| Delivery-failure alerting | Built | Three failing runs send one message, recovery sends one; the destination URL is still to be chosen |
| Custom domain | Verified | adserver.doohlabs.com serves production with an automatically issued certificate |
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
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.
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.
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.
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
These are recorded rather than overlooked. Building them now would have added things that can break during a demo while proving nothing extra.