A Cloudflare Worker that serves random background images from Unsplash with KV-based caching. Built with Hono.
GET /api/background?tags=mountain,fog fetches 30 photos from Unsplash for the given tags, caches the pool in Cloudflare KV for 3 days, then returns one random photo on each request.
Response:
{
"url": "https://images.unsplash.com/photo-...?w=3840&q=90&fm=jpg&fit=crop",
"location": "Dolomites, Italy",
"photographer": "John Doe",
"photographerUrl": "https://unsplash.com/@johndoe?utm_source=hub&utm_medium=referral"
}
tags defaults to landscape,forest,mountain,fog,nature view when omitted.
Tags are lowercased, stripped of anything outside [a-z0-9 -], deduplicated,
sorted, and capped at 5 before becoming the KV cache key. Forest, MOUNTAIN and
mountain,forest therefore share one cache entry rather than spending two
Unsplash calls. Normalisation lives in src/tags.ts.
The endpoint is public and the tag list comes from the caller, so a stream of unique tags would otherwise drain the account’s rate limit. A KV counter caps Unsplash calls per hour; once it is spent the worker serves the default pool instead of calling Unsplash, degrading variety rather than failing.
Per the Unsplash API guidelines,
the worker pings each photo’s download_location when it hands the photo out,
and attribution links carry utm_source=hub&utm_medium=referral.
photographerUrl points at the photographer’s profile, not the photo page.
Lists the sources this deployment can actually serve, so a client can build a picker without hard-coding one. A source needing credentials the environment does not have is absent rather than listed and broken.
[
{ "id": "stoic", "languages": ["en"], "acceptsQuery": false },
{ "id": "citatum", "languages": ["hu"], "acceptsQuery": true },
{ "id": "programming", "languages": ["en", "hu"], "acceptsQuery": false }
]
languages is what the source has content for, never what it would like to
have. acceptsQuery says whether ?q= narrows the result.
Returns the day’s quote for one source, behind a KV cache.
| Parameter | Meaning |
|---|---|
source |
A source id from /api/quote/sources. Unknown values fall back to stoic rather than being passed through — the id reaches a cache key, and the endpoint is public. |
lang |
One of the supported languages. A source that cannot serve it answers in its own language instead: picking a Hungarian-only source with an English interface should give Hungarian, not an error. |
q |
Category, for sources where acceptsQuery is true. Normalised (lowercased, accents folded, punctuation stripped, capped) before it reaches upstream or the key. |
n |
Which entry of the day’s pool to serve, wrapping past the end. Deliberately not part of the cache key: the pool is what is cached, so asking for a different entry costs no upstream call. |
The cache key is quote:<source>:<lang>[:<category>]:<YYYY-MM-DD> and holds a
pool rather than a single quote, so a client can offer “another one” without
spending an upstream call. Source and language are part of the key because they
are part of the answer — sharing one would mean the day’s first request decided
what everyone got.
Pool size is per source, because the upstreams differ. Citatum’s db caps a
request at five and forbids random ordering, and paging with honnan instead
would need a category’s size, which is unknowable — so its pool is built from
seven parallel single random requests, deduplicated, and a partial result is a
smaller pool rather than an error. stoic stays at one: it returns a single
random quote per call with no batch parameter, and a pool there would be one
request per entry against a one-person service.
No quote longer than 200 characters is cached. The limit is enforced on the way
in rather than at render time, because a long quote written to the day cache is
served to everyone until it expires. Citatum is asked upstream (maxhossz) not
to send them at all.
Sources
stoic — proxies stoic.tekloon.net, English only.citatum — Citatum, Hungarian, narrowed by q.
Needs the CITATUM_USER and CITATUM_KEY secrets; without them the source is
not advertised. Their allowance is 500 requests a day, so a KV counter caps
calls below it and a spent budget degrades to the stale quote. Anything
displaying these quotes must link back to Citatum — the response carries the
quote’s own sourceUrl for that.programming, …) — shipped with the worker under
src/static_quotes/, one file per source. No upstream, so no failure mode;
the day’s entry is derived from the date rather than picked at random.Response:
{
"text": "Waste no more time arguing about what a good man should be. Be one.",
"author": "Marcus Aurelius",
"sourceUrl": "https://www.citatum.hu/idezet/5073"
}
sourceUrl is present only for sources whose terms require attribution.
stoic.tekloon.net is a single-person service with no SLA. Every successful
fetch also updates a quote:<source>:<lang>:latest pointer to that source’s
newest good quote. If the upstream request fails, times out, or returns a body
with no quote text, the worker serves that pointer instead of failing the
widget. It is per source and language on purpose: one shared pointer would hand
a user who picked one source another source’s quote the moment theirs went
down, silently, for as long as the outage lasted. Only when nothing has ever
been cached (or the pointer has expired) does the endpoint return
503. A KV read failure (day cache or the stale pointer) is treated the same
way as an upstream failure rather than surfacing as a raw 500 — it degrades
along the same fallback chain. A KV write failure never discards a quote
that was already fetched successfully; caching is best-effort on top of the
response, not a precondition for it.
“Once per day in total” is the steady-state behaviour, not a hard guarantee: the day-cache check is a plain check-then-act with no request coalescing, so concurrent callers who all arrive before the first one has written the day key each read a miss and call upstream independently. In practice this is a handful of requests around UTC day rollover, not a whole day’s traffic — building true coalescing would need a Durable Object to serialise callers, which is disproportionate to that blast radius.
From the monorepo root:
pnpm install
Copy .dev.vars.example to apps/api/.dev.vars and fill
it in. The example names every variable the worker reads and is the file to
update when that list changes; .dev.vars itself is gitignored.
Get a free key at unsplash.com/developers → create an app → copy the Access Key.
The Citatum pair is optional. Without it the worker runs normally and simply
does not offer that source — GET /api/quote/sources leaves it out, so nothing
downstream advertises an option that cannot work. The code is requested by mail
through their API page and is tied to a
registered username, which is why both halves are needed.
In production both are set with wrangler secret put, not in wrangler.toml.
cd apps/api
pnpm dev
# Worker runs at http://localhost:8787
Test it:
curl "http://localhost:8787/api/background?tags=mountain"
pnpm typecheck # tsc --noEmit
pnpm lint # ESLint
pnpm test # Vitest — exercises the Hono app against an in-memory KV stub
pnpm check # all three
The API deploys to Cloudflare Workers via GitHub Actions on every api@* tag push.
Requires Cloudflare credentials in your environment:
export CLOUDFLARE_API_TOKEN=your_token
export CLOUDFLARE_ACCOUNT_ID=your_account_id
cd apps/api
pnpm deploy
CLOUDFLARE_API_TOKENCLOUDFLARE_ACCOUNT_IDgit tag api@1.0.6
git push origin api@1.0.6
GitHub Actions runs .github/workflows/deploy-api.yml, which runs pnpm nx deploy api.
wrangler.toml — Cloudflare Worker config:
name = "hub-api"
compatibility_date = "2024-03-20"
[[kv_namespaces]]
binding = "UNSPLASH_CACHE"
id = "<production-kv-id>"
preview_id = "<dev-kv-id>"
The KV namespace IDs point to Cloudflare KV stores used to cache image pools. Update these if you fork the project.
| Tool | Purpose |
|---|---|
| Hono | Web framework |
| Cloudflare Workers | Serverless runtime |
| Cloudflare KV | Image pool cache |
| Wrangler | CLI and dev server |
| TypeScript | Language |