hldays

Building hldays #9: The architecture, TypeScript on Cloudflare Workers

hldays has no database, no server I manage, and no container. It's two Cloudflare Workers and a folder of YAML files. This post walks through how that fits together and where it hurts.

The short version

  • Language: TypeScript everywhere, including the build scripts.
  • API: a Hono app running on Cloudflare Workers at api.hldays.dev. Not Node: Node is only used for builds and tests.
  • Data: YAML in the repo, validated and compiled to JSON at build time, then bundled into the Worker.
  • Site: a second Worker for hldays.dev (home page, search, docs, blog) that talks to the API through a service binding.
  • Deploys: Cloudflare's Git integration builds and deploys on every push to main.
  • CI: GitHub Actions runs the typecheck and the tests, nothing else.

Why a Worker

The API is read-only and the whole dataset is small. Every request is "take a place and a date, expand some rules, return JSON". There's no state to keep between requests, which is the case Workers are built for: no server to patch, and it runs close to whoever calls it.

Hono was an easy choice for the routing. It's small, it runs on Workers without adapters, and its app.request() lets me test routes without a server (more on that below).

I didn't seriously compare other platforms. Workers looked like the obvious fit from the start.

Data as code, no database

All the data lives in data/<country>/:

data/PT/country.yaml        # country metadata (weekend days) + national holidays
data/PT/subdivisions.yaml   # districts and autonomous regions
data/PT/municipalities.yaml # 308 municipalities
data/observances/*.yaml     # religious and cultural observances

A municipality is a few lines. Braga:

    name: "Braga"
    parent: PT-03
    codes: { ine: "0303" }
    holidays:
      - id: feriado-municipal
        name: { pt: "São João Baptista" }
        type: local
        rule: { fixed: "06-24" }
        status: verified
        source: { url: "https://irn.justica.gov.pt/Contactos-do-Registo/Registo-Civil-de-Braga", verified_at: "2026-10-16", quote: "Encerrado no feriado municipal (24 de junho)" }

scripts/build-data.ts reads every YAML file, runs it through the validator in src/core/validate.ts, and writes src/generated/data.json. If anything is wrong (an unknown rule kind, a verified record without a source URL, a parent that doesn't exist), the build stops:

const { dataset, errors } = buildDataset(files);
if (!dataset) {
  console.error(`Data validation failed:\n  ${errors.join("\n  ")}`);
  process.exit(1);
}

The Worker then imports that JSON like any module:

import data from "./generated/data.json";
const calendar = new Calendar(data as unknown as Dataset);

Right now that's 440 places, 716 holiday rules and 46 observances, about 350 KB of JSON. It's parsed once per Worker instance and lives in memory.

What this buys me:

  • No database to run, pay for or migrate. A data change is a commit.
  • Review is a diff. Every change to a holiday is visible in Git, with the source next to it.
  • Bad data can't ship. The validator runs in the build, so a broken YAML file fails the deploy, not a customer's request.

What it costs:

  • Every data fix is a deploy. That's fine for holidays, which change a few times a year, but it wouldn't work for data that changes by the minute.
  • Bundle size is a ceiling. Workers have a script size limit. At 350 KB I'm far from it, but a country with tens of thousands of municipalities would make me rethink this.
  • Generated files must exist first. src/generated/ is gitignored, so on a fresh checkout anything that imports src/ fails until npm run build:data has run. Every npm script that needs it runs it first.

The engine

src/core/ is plain TypeScript with no Worker or Hono dependency:

  • dates.ts expands rules (fixed, easter, nth_weekday, Orthodox Easter, calendar dates in other calendars) into dates for a year.
  • calendar.ts resolves a place, walks up its parents to collect inherited holidays, applies substitute days, and does the business-day maths.
  • observances.ts handles the religious and cultural calendars.
  • validate.ts checks the data at build time.

Islamic and Hebrew dates come from Intl.DateTimeFormat with the right calendar, which I checked gives the same results on Workers as in Node. The code refuses to silently fall back to Gregorian:

// Intl silently falls back to Gregorian when a runtime lacks the calendar; wrong dates are worse than an error.
if (fmt.resolvedOptions().calendar !== calendar) throw new Error(`Intl calendar ${calendar} is not supported`);

The Chinese calendar is the exception: ICU gets a few month starts wrong by a day (including Chinese New Year 2027 and 2030), so it uses a table imported from the Hong Kong Observatory instead. That story is in the post on religious and cultural observances.

src/index.ts is the HTTP layer on top: routes, parameter validation, errors, and plan gating. src/mcp.ts is the MCP server, which calls those same routes (see the previous post, on MCP).

Two Workers and a service binding

hldays.dev is its own Worker (site/). It has no data of its own. It gets everything from the API through a service binding, declared in site/wrangler.toml:

[[services]]
binding = "API"
service = "hldays"
entrypoint = "SiteApi"

SiteApi is a named entrypoint in src/worker.ts, the API Worker's real main:

export class SiteApi extends WorkerEntrypoint<Env> {
  fetch(request: Request) {
    return app.fetch(request, { ...this.env, SITE_CALLER: true });
  }
}

It can only be reached through the binding, never from the internet, so it can safely pass a SITE_CALLER flag that gives full data. The home page search shows municipal holidays because of it. The site then limits what it exposes: one place and one year per lookup, only last year to two years ahead, and a per-visitor rate limit (LOOKUP_LIMITER).

src/worker.ts exists as a separate file because cloudflare:workers only exists on the Workers runtime. Tests import src/index.ts directly and never touch it.

Rate limits as bindings

Both Workers use Cloudflare's rate limiting binding instead of a counter in a database:

[[ratelimits]]
name = "ANON_LIMITER"
namespace_id = "1001"
simple = { limit = 30, period = 60 }

On the API, callers who come straight to api.hldays.dev without RapidAPI get the free plan at 30 requests per minute per IP. The code is one call:

if (!(await c.env.ANON_LIMITER.limit({ key })).success) {
  c.header("Retry-After", "60");
  throw new ApiError(429, "rate_limited", "Free tier rate limit reached; try again in a minute." + upgradeHint(c, "For higher limits"));
}

The limits are per Cloudflare location and approximate, which is fine for "don't let one script hammer the free tier", and not what I'd use for billing. Paid quotas are enforced by RapidAPI.

Deploys and CI

There's no deploy step in GitHub Actions. Cloudflare's Git integration watches the repo, runs the [build] command from wrangler.toml (npm run build:data), and deploys on every push to main. The site Worker has its own build command for the blog.

GitHub Actions only does this:

- run: npm ci
- run: npm run typecheck
- run: npm test

Early on, CI also deployed with wrangler and pushed the OpenAPI spec to RapidAPI. I removed both, and the RapidAPI listing is now updated by re-importing /openapi.json by hand (more on that in the next post).

I moved deploys to Cloudflare because it's simpler. The workflow stays lean: merging to main deploys, and when I need a development environment, a branch gets its own preview deployment.

One gotcha: after a push, the new version takes about a minute to reach every edge location, and in that window some requests still hit the old one. So before testing a change live, I poll for something only the new version returns.

Testing without running the Worker

I don't run the Worker locally with wrangler dev at all. Hono's app.request() runs a request through the app in plain Node, with whatever env I pass in:

const get = (path: string, env: Record<string, unknown> = {}, headers: Record<string, string> = {}) =>
  app.request(path, { headers }, env);

Bindings are faked in the test. The rate limiter is an object with a limit method. The site's service binding is the neatest one: test/site.test.ts wires the site straight to the real API app, doing what SiteApi does in production:

const env = (extra: Partial<SiteEnv> = {}): SiteEnv => ({
  API: { fetch: (req) => Promise.resolve(api.fetch(req, { RAPIDAPI_PROXY_SECRET: "s3cret", SITE_CALLER: true })) },
  API_URL: "https://api.hldays.dev",
  SITE_URL: "https://hldays.dev",
  PRICING_URL: "https://example.com/pricing",
  ...extra,
});

Note that it sets the proxy secret too, like production: so the site tests also prove that SITE_CALLER is what grants full data, not the absence of a secret. That's how 74 tests across the engine, the API, MCP and the site run in vitest, locally and in CI.

The trade-off is real, though: I'm testing in Node, not in workerd. Anything that differs between the two runtimes (like the Intl calendars) I have to check against the deployed Worker.

What I'd watch

  • The bundled-data approach is the thing most likely to need changing, if a country ever needs far more places than Portugal.
  • Two Workers mean two deploys that can be briefly out of step, since each one deploys on its own.

How do you handle data that changes a few times a year: database, or files in the repo? I'd like to hear where people draw the line.

Next in the series: docs that sell the API, with OpenAPI, Redoc and the homepage demo.

hldays.dev · Docs