Web pro více zemí z jednoho kódu

Společný kód, individualizace pro každou zemi a vlastní doména na Cloudflare Workers - případová studie DatováMapa

Jak jsme postavili DatovouMapu pro více zemí: jedno jádro, kontrakt, který každá země naplní, adresy a texty v jazyce země, funkce podle toho, co země umí, a každá země jako samostatný Worker na vlastní doméně. Technicky, s ukázkami kódu z produkce. Jak to funguje, uvidíte rovnou na datovamapa.cz.

Vladimír NeporAutorIng. Vladimír Nepor
Publikováno
Web pro více zemí z jednoho kódu: jádro (routy, API, MCP, mapa, cache) zná zemi jen přes kontrakt z konfigurace, obsahu a volitelných služeb. Před každým buildem symlink src/country vybere jednu zemi a každá země běží na vlastní doméně s vlastním jazykem, adresami, zdroji dat a službami.Jádrosrc/app · routyAPI · MCP · mapacache · registraceo žádné zemi nevíKontrakt zeměCountryConfigcontent · services?@/country/…symlink před buildemcountries/czdatovamapa.czcs-CZ · /mista · 3 službycountries/skdatovamapa.sksk-SK · /miesta · 2 služby02 · KONTRAKTCo dodává zeměconfig · content · serviceskapitola →03 · NAČTENÍ ZEMĚSymlink src/countrybundle jen s jednou zemíkapitola →04 · LOKALIZACEAdresy a texty země/mista × /miesta, t("…")kapitola →07 · VLASTNÍ DOMÉNAWorker pro každou zemiCOUNTRY=sk npm run deploykapitola →
Obsah článku

00 · Přehled

Web pro více zemí v kostce

DatováMapa běží ve více zemích z jednoho kódu. Každá země má vlastní doménu, jazyk, adresy stránek, zdroje dat i služby, jádro aplikace ale existuje jen jednou a o žádné zemi neví. Ukazujeme, jak je to postavené: hranici mezi jádrem a zemí, kontrakt, který ji hlídá, lokalizaci a provoz každé země jako samostatného Workeru na Cloudflare.

Architektura ve zkratce

  1. Jádro - routy, protokol, API, MCP, mapa a cache v src/, bez jediné zmínky o konkrétní zemi
  2. Kontrakt - tři typy (CountryConfig, obsah, CountryServices) určují, co země musí a co může dodat
  3. Země - složka countries/<id>: konfigurace, zdroje dat, obsah, překlady a dokumentace v jazyce země
  4. Výběr před buildem - symlink src/country vybere jednu zemi, bundle obsahuje jen ji
  5. Vlastní doména - každá země je samostatný Worker s vlastní doménou, D1 a nasazením

Co v článku najdete

TémaKapitola
Co patří do jádra a co do zeměJádro a země →
Kontrakt, který hranici typově hlídáKontrakt země →
Jak se jádro dostane k zemi, aniž by o ní věděloNačtení země →
Adresy a texty v jazyce každé zeměLokalizace →
Funkce, které má jen některá zeměVolitelné služby →
Jak změna jádra nerozbije jinou zemiKontrola všech zemí →
Každá země jako samostatný Worker na vlastní doméněVlastní domény →
Pravidla, která se nám osvědčilaPoučení →
Případová studie: DatováMapa

DatováMapa je otevřený portál, který v jedné mapě spojuje veřejná data z různých zdrojů. Architekturu navrhl, postavil a provozuje Vrealmatic: Next.js 16 přes OpenNext na Cloudflare Workers. Ukázky kódu jsou zkrácené z produkčního repozitáře, ID databází jsou nahrazena zástupnými hodnotami. Stav k říjnu 2026.

01 · Architektura

Co patří do jádra a co do země

Celá architektura stojí na jednom pravidle: jádro nesmí předpokládat konkrétní zemi ani konkrétní data. Všechno, co se mezi zeměmi liší, patří do složky země. Jádro to čte jen přes kontrakt a s chybějícími částmi počítá.

Tři vrstvy: jádro nahoře je pro všechny země stejné (routy, protokol, API a MCP, mapa, cache, registrace). Se zemí mluví jen přes kontrakt uprostřed. Dole každá země kontrakt naplní vlastním jazykem, adresami, zdroji dat, službami a doménou.Jádro (src/): pro všechny země stejnéroutyprotokol · API · MCPmapacacheregistraceKontrakt: CountryConfig · content · CountryServicescountries/czJazykcs-CZAdresy/mista, /dokumentaceZdroje datČSÚ, ČÚZK, města…Službygeokodér, hranice, krajeDoménadatovamapa.czcountries/skJazyksk-SKAdresy/miesta, /dokumentaciaZdroje datŠÚ SR, ÚGKK, mestá…Službyhranice, krajeDoménadatovamapa.skZemě kontrakt naplní, jádro se nikdy neptá, o kterou zemi jde
Tři vrstvy: jádro je pro všechny země stejné, kontrakt říká, co země dodává, a každá země ho naplní po svém.

Co patří kam

OblastJádro (src/)Země (countries/<id>)
Routy a stránkyneutrální anglické routy, layout, komponentyveřejné adresy v jazyce země (/mista × /miesta)
Protokol, API, MCPschéma, validace, endpointy, nástroje: všude stejné a anglickénic
Texty pro lidičeský zdrojový text ve volání t("…")katalog překladů messages.ts a dokumentace v docs/*.md
Identitajen typ CountryConfigid, jazyk, kanonická doména, časové pásmo, kraje, výřez mapy
Zdroje datproxy, adaptéry, cache, katalogregistr poskytovatelů a jejich adaptéry v providers/
Místopishledání, stránky obcí a krajůobce, kraje a hranice (JSON z vlastních skriptů země)
Obsahjak se vykreslísestavené mapy, tematické bloky obce, redakční kalendář, sociální sítě
Službyco se stane, když služba chybígeokodér, hranice obcí, čísla za kraj (každá nepovinná)
Provozworker.ts, cache, migrace D1vlastní wrangler.jsonc: Worker, D1, doména, e-mail

Proč ne fork pro každou zemi

Jádro + země

  • oprava chyby nebo nová funkce v jádru platí pro všechny země
  • nová země = nová složka, první verze může být jen kostra
  • rozdíly mezi zeměmi jsou na jednom místě a typově hlídané

Fork nebo větvení podle země

  • každá oprava se přenáší ručně a forky se rozjíždějí
  • if (country === "sk") rozeseté po kódu, které nikdo neudrží
  • nikdo neví, jestli změna nerozbila zemi, kterou zrovna nevyvíjí
Kdy stačí jeden Worker pro všechny domény

Pokud se domény liší jen brandingem a canonical URL (white-label), nepotřebujete vrstvu zemí ani Worker pro každou doménu. Jak v tom případě bezpečně cachovat, popisuje kapitola Jeden Worker, více domén v návodu Workers Cache.

02 · Kontrakt

Kontrakt země: co musí a co může dodat

Kontrakt jsou tři typy v jádru. Říkají, co země musí dodat (konfiguraci), co může dodat prázdné (obsah) a co nemusí mít vůbec (služby). Tvar hlídá TypeScript: země, která něco vynechá nebo pojmenuje jinak, neprojde typovou kontrolou.

Text
.
├── src/                          # core: knows a country only through the contract
│   ├── app/                      # routes with neutral English names
│   ├── lib/country-config.ts     # contract: identity, language, domain, public paths
│   ├── lib/country-content.ts    # contract: curated maps, place themes, calendar
│   ├── lib/country-services.ts   # contract: optional services
│   └── country -> ../countries/cz   # symlink to the selected country
├── countries/
│   ├── index.ts                  # registry of all countries (links between them)
│   ├── cz/
│   │   ├── config.ts             # CountryConfig
│   │   ├── services.ts           # CountryServices
│   │   ├── messages.ts           # UI translations (empty for Czech)
│   │   ├── providers/            # the country's data sources
│   │   ├── content/              # curated maps, gazetteer, country outline
│   │   ├── docs/                 # public documentation in the country's language
│   │   └── scripts/              # generators of the country's data
│   └── sk/                       # the same shape + its own wrangler.jsonc
├── worker.ts                     # one entry point for every country
├── wrangler.jsonc                # worker of the default country (cz)
└── scripts/                      # use-country, check-countries, cf (deploy)

CountryConfig: identita a adresy

Konfigurace je jediný povinný popis země: kdo je, jakým jazykem mluví, na jaké doméně bydlí a jak se v jejím jazyce jmenují stránky. Klíče stránek, souborů a dokumentů jsou neutrální (places, docs) a hodnoty jsou v jazyce země.

src/lib/country-config.ts
// src/lib/country-config.ts (shortened): everything the core may know about a country
export type PageKey = "places" | "maps" | "topics" | "sources" | "docs" | "register" | "unreviewed" | "datasets";
export type FileKey = "places.geojson" | "municipalities.json" | "sources.md" | "examples";
export type DocKey = "providers" | "ai-agents" | "ai-assistants" | "protocol" | "api" | "about";

export type CountryConfig = {
  id: string;                                // "cz", the same as the folder name
  locale: string;                            // "cs-CZ": language of texts for people
  origin: string;                            // canonical domain: "https://datovamapa.cz"
  bounds: [number, number, number, number];  // where the map opens
  timeZone: string;                          // what "today" means
  regions: { name: string; slug: string }[]; // top-level administrative units
  basemap?: { tileJsonUrl?: string; tileUrl?: string; attribution?: string };
  paths: {
    pages: Record<PageKey, string>;          // public URL segments in the country's language
    files: Record<FileKey, string>;
    docs: Record<DocKey, string>;
    categories: Record<string, string>;
  };
};
countries/sk/config.ts
// countries/sk/config.ts (shortened)
import type { CountryConfig } from "../../src/lib/country-config";

export const country: CountryConfig = {
  id: "sk",
  locale: "sk-SK",
  origin: "https://datovamapa.sk",
  bounds: [16.83, 47.73, 22.57, 49.62],
  timeZone: "Europe/Bratislava",
  regions: [
    { name: "Bratislavský kraj", slug: "bratislavsky-kraj" },
    // ... 7 more
  ],
  // until Slovakia has its own tile server: OpenFreeMap (OpenMapTiles schema)
  basemap: { tileJsonUrl: "https://tiles.openfreemap.org/planet" /* , attribution: ... */ },
  paths: {
    pages: {
      places: "miesta", maps: "mapy", topics: "temy", sources: "zdroje",
      docs: "dokumentacia", register: "registracia", unreviewed: "neschvalene-data", datasets: "mapa",
    },
    // files, docs and categories the same way
  },
};

Co musí složka země obsahovat

SouborObsahMůže být prázdný?
config.tsidentita, jazyk, doména, výřez mapy, kraje, veřejné cestyne
providers/registry.tspřipojení poskytovatelé dat a jejich adaptéryano
content/maps.ts, place-themes.ts…sestavené mapy, bloky přehledu obce, redakční kalendář, sociální sítěano
content/docs.ts + docs/*.mdveřejná dokumentace v jazyce zeměne
messages.tspřeklady textů z jádrau výchozího jazyka ano
content/places.json…místopis: obce, kraje, hranice (generují skripty země)ne
content/outline.jsonobrys státu pro mapu a prokliky mezi zeměmine
services.tsvolitelné službyano
wrangler.jsoncWorker země (jen mimo výchozí zemi)-
Kontrakt roste opatrně

Každé nové pole v kontraktu musí doplnit všechny země. Proto je výhodné nové věci přidávat jako nepovinné (služba, prázdný obsah) a povinnými je dělat jen tehdy, když bez nich jádro opravdu nemůže fungovat.

03 · Načtení

Jak se jádro dostane k zemi

Jádro importuje zemi vždy přes jedinou cestu @/country/…. Za ní je symbolický odkaz src/country, který skript před buildem nasměruje na countries/cz, nebo countries/sk. Jádro tak nikdy neřeší, o kterou zemi jde.

TypeScript
// the core reaches the selected country through one path only
import { country } from "@/country/config";
import { services } from "@/country/services";
import { registry } from "@/country/providers/registry";
scripts/use-country.ts
// scripts/use-country.ts (shortened): src/country -> countries/<COUNTRY>
import { existsSync, lstatSync, readlinkSync, symlinkSync, unlinkSync } from "node:fs";
import { join } from "node:path";

const root = join(import.meta.dirname, "..");
const id = process.env.COUNTRY ?? "cz";
if (!/^[a-z]{2,8}$/.test(id) || !existsSync(join(root, "countries", id, "config.ts"))) {
  console.error(`Country "${id}" does not exist: countries/${id}/config.ts is missing.`);
  process.exit(1);
}

const link = join(root, "src", "country");
const target = join("..", "countries", id);
const stat = lstatSync(link, { throwIfNoEntry: false });
if (stat && !stat.isSymbolicLink()) {
  console.error("src/country must be a symlink, not a folder.");
  process.exit(1);
}
if (!stat || readlinkSync(link, { encoding: "utf8" }) !== target) {
  if (stat) unlinkSync(link);
  symlinkSync(target, link, "dir");
}

Přepnutí zařídí npm skripty samy: dev i build nejdřív přepnou symlink podle COUNTRY a vygenerují katalog překladů pro prohlížeč. Ručně stačí COUNTRY=sk npm run country.

package.json
// package.json: dev and build switch the country by themselves
"predev":   "tsx scripts/use-country.ts && tsx scripts/i18n.ts --write",
"prebuild": "tsx scripts/use-country.ts && tsx scripts/i18n.ts --write",
"country":  "tsx scripts/use-country.ts"

Symlink před buildem

  • bundle obsahuje jen data jedné země, Worker neroste s počtem zemí
  • statické importy a plné typy, žádné větvení podle země
  • typová kontrola běží proti skutečné zemi, ne proti sjednocení všech

process.env.COUNTRY za běhu

  • bundler zabalí data všech zemí najednou
  • dynamický import s proměnnou cestou (import(`./countries/${id}`)) bundler často nerozpozná
  • chyba v datech jedné země může rozbít build všech

Registr všech zemí

Jednu věc potřebuje každá země vědět o ostatních: že existují. Mapa DatovéMapy zakryje okolní státy a podporované země nabídne jako proklik na jejich doménu. K tomu slouží malý registr nezávislý na symlinku. Obsahuje jen konfiguraci a obrys, žádná data.

countries/index.ts
// countries/index.ts: every country, independent of the symlink
import type { CountryConfig } from "../src/lib/country-config";
import { country as cz } from "./cz/config";
import czOutline from "./cz/content/outline.json";
import { country as sk } from "./sk/config";
import skOutline from "./sk/content/outline.json";

export type CountryEntry = { config: CountryConfig; outline: [number, number][][][] };

export const countries: CountryEntry[] = [
  { config: cz, outline: czOutline.shape as [number, number][][][] },
  { config: sk, outline: skOutline.shape as [number, number][][][] },
];
Velká data mimo hlavní bundle

I jedna země může přerůst limit velikosti Workeru. Velké soubory (u DatovéMapy například zdravotnictví) se importují líně přes await import() a malá metadata k nim leží zvlášť v *.meta.json.

04 · Lokalizace

Adresy a texty v jazyce země

Každá země má adresy i texty ve svém jazyce: datovamapa.cz/mista, datovamapa.sk/miesta. Uvnitř aplikace se ale nic nepřekládá ručně. Routy jsou neutrální, odkazy se skládají přes funkce a texty přes katalog země.

Adresy stránek v jazyce země

  • Routy v src/app mají neutrální anglické názvy (places, docs). Veřejné adresy na ně vedou přes rewrites vygenerované z config.paths.
  • Každý odkaz vzniká přes routeHref, nikdy ručně. Stejný kód vytvoří /dokumentace/api v Česku a /dokumentacia/api na Slovensku.
  • Interní adresy se nevydávají. worker.ts požadavek na interní routu přesměruje na veřejnou, aby stránka neexistovala pod dvěma URL.
src/lib/paths.ts
// src/lib/paths.ts (shortened): neutral routes inside, the country's language outside
import { country } from "../country/config";
const { pages: PAGE_PATHS } = country.paths;

// routeHref("docs", "api") -> "/dokumentace/api" in cz, "/dokumentacia/api" in sk
export const routeHref = (key: PageKey, ...rest: string[]) => `/${[PAGE_PATHS[key], ...rest].join("/")}`;

// for next.config.ts: public segment -> internal route
export function pathRewrites() {
  return Object.entries(PAGE_PATHS).flatMap(([internal, publicName]) => [
    { source: `/${publicName}`, destination: `/${internal}` },
    { source: `/${publicName}/:path*`, destination: `/${internal}/:path*` },
  ]);
}

Texty: zdrojový řetězec je klíč

Jádro píše texty česky přímo v kódu, t("…") je zároveň klíč. Česko má katalog prázdný, Slovensko v countries/sk/messages.ts překládá zdroj na slovenštinu. Chybějící překlad vrátí zdrojový text, takže nová funkce v jádru nikdy nerozbije jinou zemi. Ve vývoji na chybějící překlad upozorní konzole.

Prohlížeč nedostane celý katalog. Skript vybere jen překlady textů z klientských komponent (messages.client.ts, generuje se před buildem a není v gitu) a Next.js ho prohlížeči podstrčí přes alias. Server používá celý katalog.

TypeScript
// in the core: the Czech source text is the key
const text = t("Chyba: {message}", { message });

// countries/sk/messages.ts: Czech source -> Slovak translation (the Czech catalog is empty)
export const messages: Record<string, string> = {
  "DatováMapa": "DátováMapa",
  "## Dokumentace": "## Dokumentácia",
  "[lon, lat] ve WGS84, např. [14.4378, 50.0755]": "[lon, lat] vo WGS84, napr. [17.1077, 48.1486]",
};
next.config.ts
// next.config.ts: the browser gets only the translations of client code, the server the whole catalog
turbopack: {
  resolveAlias: {
    "@/country/messages": { browser: "./src/country/messages.client.ts" },
  },
},
async rewrites() {
  return pathRewrites();
},
Terminál
COUNTRY=sk npm run i18n                # sources, missing translations, unused keys
COUNTRY=sk npm run i18n -- --missing   # missing sources as JSON, input for translation
COUNTRY=sk npm run i18n -- --check     # fails when anything is missing or left over

Co se nepřekládá

Protokol, API, MCP nástroje, chybové kódy a JSON Schema jsou ve všech zemích stejné a anglické. Klient nebo AI agent, který umí jednu zemi, tak umí všechny. Zemi patří jen to, co čtou lidé: texty stránek, dokumentace, názvy míst a adresy.

05 · Služby

Volitelné služby a zdroje dat

Země nemají stejné možnosti. Česko má veřejný geokodér adres (RÚIAN), Slovensko zatím ne. Kontrakt to řeší nepovinnými službami: co země nemá, to jádro vypne, a nespadne.

src/lib/country-services.ts
// src/lib/country-services.ts (shortened): every service is optional
export type CountryServices = {
  /** Address, street or municipality search. */
  geocoder?: { attribution: string; search(text: string): Promise<GeocoderHit[]> };
  /** Municipality boundary by code. */
  boundaries?: { download(code: string): Promise<Ring[][] | undefined> };
  /** Figures for a region. */
  regionStatistics?: { providerId: string; load(regionName: string): Promise<RegionFigures[]> };
};
countries/sk/services.ts
// countries/sk/services.ts: no address search yet, so the core switches it off
export const services: CountryServices = {
  boundaries: { download: async (code) => shapes.get(code) },
  regionStatistics: { providerId: REGION_STATISTICS_PROVIDER, load: regionStatistics },
};
TypeScript
// src/app/[region]/page.tsx: no statistics service, no figures on the region page
const statistics = (await services.regionStatistics?.load(region.name)) ?? [];

// src/lib/server/geocode.ts: no geocoder, a clear error instead of a crash
const geocoder = services.geocoder;
if (!geocoder) throw new RequestError(t("Hledání adres a míst v této zemi zatím není dostupné."), 501, "geocoding_unavailable");
  • Bez geokodéru vrátí hledání míst chybu geocoding_unavailable a MCP ani OpenAPI nástroj find_place vůbec nenabízí.
  • Bez čísel za kraj má stránka kraje jen seznam obcí a dat, bez karet s čísly.
  • Bez hranic obcí portál nezná hranici menších obcí a jejich data ukáže jako nenačtená, aby neuváděl počty za špatné území.

Zdroje dat patří zemi

Každá země má vlastní registr poskytovatelů (providers/registry.ts): Česko ČSÚ, ČHMÚ nebo data měst, Slovensko ŠÚ SR, ÚGKK, Bratislavu a Košice. Adaptéry na rozhraní mimo protokol (ArcGIS REST, vlastní JSON) jsou v jádru a země je jen používá. Proto může nová země začít s prázdným registrem a zdroje přidávat postupně.

Zapamatujte si
  • Nová schopnost = nepovinná služba v kontraktu. Země ji dodá, až ji má.
  • Jádro na chybějící službu reaguje vypnutím funkce, ne chybou.
  • Zemi nikdy neblokuje to, co jiná země umí navíc.

06 · Kontrola

Kontrola všech zemí jedním příkazem

Největší riziko sdíleného jádra: změníte ho při práci na jedné zemi a rozbijete druhou, kterou zrovna nevyvíjíte. Proto jeden příkaz projde všechny země a pro každou spustí stejné kontroly.

scripts/check-countries.ts
// scripts/check-countries.ts (shortened): every country, one after another
const countries = readdirSync("countries").filter((id) => existsSync(`countries/${id}/config.ts`));
const previous = basename(readlinkSync("src/country", { encoding: "utf8" }));
const run = (id: string, args: string[]) =>
  spawnSync("npx", args, { stdio: "inherit", env: { ...process.env, COUNTRY: id } }).status === 0;

const failed: string[] = [];
try {
  for (const id of countries) {
    run(id, ["tsx", "scripts/use-country.ts"]);
    if (!run(id, ["tsc", "--noEmit"])) failed.push(`${id}: types`);
    if (!run(id, ["tsx", "scripts/check-docs.ts"])) failed.push(`${id}: docs`);
    if (!run(id, ["tsx", "scripts/i18n.ts", "--check"])) failed.push(`${id}: translations`);
  }
} finally {
  run(previous, ["tsx", "scripts/use-country.ts"]); // back to the country selected before
}
if (failed.length) process.exit(1);
KontrolaCo odhalí
tsc --noEmitzemi, které chybí nové pole kontraktu, nebo jádro, které předpokládá data jedné země
check-docs.tsnefunkční ukázky a odkazy v dokumentaci země, kolizi adresy kraje s routou
i18n.ts --checkchybějící nebo nepoužité překlady
registr a obryszemi, která chybí v countries/index.ts nebo nemá outline.json
  • Po každé změně jádra spusťte npm run check:countries, i když nasazujete jen jednu zemi.
  • Změna protokolu = změna schématu a dokumentace protokolu všech zemí, kontrola to ověří.
  • Skript na konci vrátí symlink na zemi, která byla vybraná předtím, takže vás nepřepne jinam.

07 · Domény

Každá země na vlastní doméně

Individualizace končí u infrastruktury: každá země je samostatný Cloudflare Worker na vlastní doméně, s vlastní databází D1, rate limitem, odesílatelem e-mailů a secrets. Výpadek, chybná data nebo nasazení jedné země se druhé nedotkne.

wrangler.jsonc ve složce země

Výchozí země (Česko) používá kořenový wrangler.jsonc, ostatní země vlastní soubor ve své složce. Cesty v něm jsou relativní k souboru (../../worker.ts), takže všechny země sdílí stejný vstupní bod a výstup buildu.

countries/sk/wrangler.jsonc
// countries/sk/wrangler.jsonc: every path is relative to THIS file
{
  "$schema": "../../node_modules/wrangler/config-schema.json",
  "name": "datovamapa-sk",
  "main": "../../worker.ts",
  "workers_dev": false,
  "preview_urls": false,
  "compatibility_date": "2026-08-12",
  "compatibility_flags": ["global_fetch_strictly_public"],
  "assets": { "binding": "ASSETS", "directory": "../../.open-next/assets" },
  "images": { "binding": "IMAGES" },
  // must match "name": a copied "datovamapa" would call the Czech worker
  "services": [{ "binding": "WORKER_SELF_REFERENCE", "service": "datovamapa-sk" }],
  "d1_databases": [
    { "binding": "DB", "database_name": "datovamapa_sk", "database_id": "<D1_ID_SK>", "migrations_dir": "../../migrations" }
  ],
  // namespace_id must be unique in the account (cz uses 1001)
  "ratelimits": [{ "name": "REGISTRATION_LIMIT", "namespace_id": "2001", "simple": { "limit": 5, "period": 60 } }],
  "send_email": [{ "name": "EMAIL" }],
  "version_metadata": { "binding": "CF_VERSION_METADATA" },
  "vars": { "NOTIFY_FROM": "registracia@mail.datovamapa.sk" },
  "observability": { "enabled": true },
  "upload_source_maps": true,
  "limits": { "cpu_ms": 750 }
}
KlíčČeskoSlovenskoProč zvlášť
namedatovamapadatovamapa-sksamostatný Worker, verze a logy
d1_databasesdatovamapa-d1datovamapa_skregistrace a schválení patří jedné zemi
namespace_id10012001se stejným ID by země sdílely počítadlo
NOTIFY_FROMmail.datovamapa.czmail.datovamapa.skodesílatel na doméně země
WORKER_SELF_REFERENCEdatovamapadatovamapa-skOpenNext volá sám sebe

Nasazení jedné země

Všechny příkazy (build, nasazení, migrace D1, schvalování) jdou přes skript, který podle COUNTRY přepne symlink a Wrangleru doplní --config. Člověk ho nikdy nepíše ručně, takže slovenský build nemůže skončit v českém Workeru.

scripts/lib/wrangler.ts
// scripts/lib/wrangler.ts: the country's worker and D1 for every wrangler call
import { existsSync } from "node:fs";
import { join } from "node:path";

export const root = join(import.meta.dirname, "..", "..");
export const countryId = process.env.COUNTRY ?? "cz";

const config = join("countries", countryId, "wrangler.jsonc");
/** "--config ..." for a country with its own config, otherwise nothing (cz uses the root file). */
export const wranglerConfigArgs = existsSync(join(root, config)) ? ["--config", config] : [];
Terminál
npm run deploy                # Czechia: root wrangler.jsonc
COUNTRY=sk npm run deploy     # Slovakia: countries/sk/wrangler.jsonc
Pipeline nasazení v šesti krocích: use-country.ts přepne symlink, prebuild zapíše i18n katalog pro prohlížeč, next build nastaví verzi, OpenNext sestaví Worker a assets, Wrangler zabalí worker.ts a deploy přepne provoz na novou verzi. Při COUNTRY=sk krok 1 míří na countries/sk a kroky 4 až 6 dostanou --config countries/sk/wrangler.jsonc.1use-country.tspřepne src/country2prebuildi18n katalog pro prohlížeč3next buildverze z počtu commitů4OpenNext build.open-next/ (Worker + assets)5wrangler bundleworker.ts + .open-next6deploynová verze přebírá provozCOUNTRY=sk → countries/sk + --config countries/sk/wrangler.jsonc (kroky 4–6)
Co udělá jeden příkaz. COUNTRY rozhoduje o tom, kam míří symlink, a o konfiguraci Wrangleru.

Doména země a odkazy mezi zeměmi

  • config.origin je kanonická doména země. Canonical, Open Graph, sitemap i odkazy v dokumentaci míří na ni a každá země je pro vyhledávače samostatný web ve svém jazyce.
  • Edge cache ve worker.ts (Cache API) má v klíči celou URL včetně domény a ID nasazené verze. Nasazení jedné země vyprázdní jen její cache. Cache API nefunguje na *.workers.dev, proto mají Workery workers_dev: false a běží jen na vlastní doméně.
  • Prokliky mezi zeměmi skládá mapa z registru countries/index.ts: název sousední země v jazyce té aktuální (Intl.DisplayNames) a odkaz na její origin.

08 · Poučení

Co se nám osvědčilo

Architektura jádro + země stojí na tom, jestli hranice mezi nimi vydrží. Tahle pravidla se nám při stavbě a provozu osvědčila.

  • Země nesmí prosáknout do jádra. Jakmile se v src/ objeví country.id === "cz" nebo natvrdo zapsaná česká adresa, hranice padla. Rozdíl vždy patří do konfigurace, obsahu nebo služby země.
  • Odkazy jen přes funkce. href="/mista" by fungoval jen v Česku, proto se všechny odkazy skládají přes routeHref a docHref.
  • Přeložit jde jen literál. Řetězec v t() musí být literál, jinak ho skript nenajde a překlad by chyběl jen v jedné zemi.
  • Kontrakt roste nepovinně. Povinné pole by zablokovalo všechny země, dokud ho nedoplní. Nové schopnosti proto začínají jako nepovinná služba nebo prázdný obsah.
  • Kontroluje se vždy všechno. tsc proti symlinku ověří jen vybranou zemi, proto každá změna jádra projde check:countries.
  • Konfigurace Workeru se nekopíruje naslepo. Stejné namespace_id by sdílelo počítadlo, zkopírovaný service binding by volal cizí Worker. Proto --config doplňuje skript, nikdy člověk.
  • Build a nasazení jsou jeden krok. .open-next/ obsahuje jen naposledy sestavenou zemi, takže se nasazuje výhradně přes skript, který zemi přepne a sestaví.

Díky tomu je nová země hlavně práce s daty: nová složka, konfigurace, místopis a překlady. Slovensko se spustilo jako kostra s místopisem z Eurostatu a OpenStreetMap, bez poskytovatelů dat a služeb, a obsah přibývá postupně jako nasazení jen slovenského Workeru.

Zapamatujte si
  • Jádro o žádné zemi neví. Zná jen kontrakt: konfiguraci, obsah a nepovinné služby.
  • Země je složka, ne fork. Do jádra se dostane přes symlink před buildem a bundle obsahuje jen ji.
  • Adresy a texty v jazyce země vznikají z konfigurace a katalogu, protokol a API zůstávají všude stejné.
  • Co země nemá, jádro vypne. Proto může nová země začít jako kostra.
  • Každá země běží na vlastní doméně jako samostatný Worker a změna jádra se vždy ověří na všech.

Zdroje

Oficiální dokumentace a další čtení

Spouštíte aplikaci ve více zemích?

Navrhneme architekturu se společným jádrem a nastavíme provoz každé země na vlastní doméně na Cloudflare.

Rezervovat schůzku