Two ways to stand up an instance. The fast path uses Cloudflare's Deploy to Cloudflare button - a git-connected deploy that provisions the Worker and R2 bucket and copies this repo into your GitHub; you add credentials, commit RDF to the repo, and a GitHub Action syncs those labels into R2. The full path clones the repo locally, where you extract a label set however suits you - a SPARQL CONSTRUCT, the bundled ontologies, your own scripts - then sync it to R2 from the command line. You can start with the fast path and later switch to the full pipeline if you wish.
No clone, no local Node. Best for vocabularies and small / modest label sets.
Large dumps, and full control over concurrency, credentials, and multiple projects.
No clone, no local Node. Best for vocabularies and small / modest label sets; re-running the seed is an upsert, not a mirror.
The deploy button can't copy GitHub Actions workflows into your repo (a GitHub security restriction), so add it once by hand: Add file → Create new file, name it .github/workflows/seed-labels.yml, and paste the contents of seed-labels.yml (use the copy raw file button). Commit it, and seed-labels appears under the repo's Actions tab.
Gather these from the Cloudflare dashboard, then add them on the repo under
Settings → Secrets and variables → Actions - SEED_BASE and
R2_BUCKET as Variables, the rest as Secrets:
*.workers.dev URL.wrangler whoami).openssl rand -hex 32), used to authorise cache purges after a reseed. Add it here as a Secret and - so the two match - as a Worker secret: dashboard → your Worker → Settings → Variables and Secrets → add PURGE_TOKEN.Drop RDF into the labels/ folder and commit. A full instance-data dump is fine - only label and description triples are extracted.
Actions → seed-labels → Run workflow (tick include common ontology labels for the bundled vocabularies).
The five steps below give full control over concurrency, credentials, and multiple projects. Already deployed with the button? Run this pipeline against that same instance - it just seeds the same bucket.
Examples use pnpm - recommended, since the committed lockfile gives
reproducible installs. The underlying commands can be adapted to npm or bun. In shell commands, swap
pnpm install → npm install / bun install and pnpm wrangler … →
npx wrangler … / bunx wrangler …; the node scripts/… commands are identical
on all three. For the just recipes, name your manager once and it threads through:
just pm=npm bootstrap.
With .env filled in (see Configuration below), run the whole flow at once with just bootstrap
(or pass an endpoint to override SPARQL_ENDPOINT: just bootstrap https://other/sparql) - or
follow the steps below individually.
git clone https://github.com/recalcitrantsupplant/rdf-label-cache && cd rdf-label-cache
pnpm install
From your triplestore, CONSTRUCT the labels and descriptions for every IRI your data uses -
your own entities and the public-vocabulary terms they reference - and save it as data.ttl:
just extract-labels https://your-endpoint/sparql # runs the query below → data.ttl
This runs an example query (expand it below) - a starting point. Write your own or adapt it to cache whatever you need: despite the name, nothing in RDF Label Cache is hardcoded to labels, it can deliver whichever triples you like, keyed by IRI - labels are just the common case.
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
PREFIX skos: <http://www.w3.org/2004/02/skos/core#>
PREFIX dcterms: <http://purl.org/dc/terms/>
PREFIX schema: <https://schema.org/>
CONSTRUCT { ?iri rdfs:label ?l ; rdfs:comment ?c ; skos:prefLabel ?p ; skos:definition ?d ;
dcterms:title ?t ; dcterms:description ?desc ; schema:name ?n }
WHERE {
{ SELECT DISTINCT ?iri WHERE {
{ ?iri ?px ?ox } UNION { ?sx ?iri ?ox } UNION { ?sx ?px ?iri FILTER(isIRI(?iri)) } } }
OPTIONAL { ?iri rdfs:label ?l } OPTIONAL { ?iri rdfs:comment ?c }
OPTIONAL { ?iri skos:prefLabel ?p } OPTIONAL { ?iri skos:definition ?d }
OPTIONAL { ?iri dcterms:title ?t } OPTIONAL { ?iri dcterms:description ?desc }
OPTIONAL { ?iri schema:name ?n }
FILTER(BOUND(?l)||BOUND(?c)||BOUND(?p)||BOUND(?d)||BOUND(?t)||BOUND(?desc)||BOUND(?n))
}
curl -sf https://your-endpoint/sparql \
--data-urlencode query@query.rq \
-H 'Accept: text/turtle' -o data.ttl
Labelling is usually two steps - your data, then the vocabularies it references.
The query above captures the labels in your own data - but not the vocabulary terms it
references (i.e. the label for rdfs:label itself) unless you've loaded those ontologies
too. If required, you can seed these separately below using bundled copies of common ontology labels and
descriptions.
Cover those public-vocabulary terms - schema:name, skos:Concept,
rdfs:label - two ways:
If you didn't include labels for these with your instance data, RDF Label Cache bundles copies you can ingest straight into R2 - no dump needed.
just seed-public # all bundled vocabularies
just seed-public skos,rdf,rdfs # …or only the ones you name
node scripts/ingest.mjs # all bundled vocabularies
node scripts/ingest.mjs --only skos,rdf # …or only the ones you name
node scripts/upload-seed.mjs
The bundled vocabularies (rdf, rdfs, owl, skos, dcterms, dcat, schema.org) come to only a few thousand terms - small even with schema.org, the largest of them. There's no real cost to putting all of them in R2, so unless you want a minimal footprint, just seed the lot. It's additive to your own labels.
Report the namespaces your data uses but doesn't itself label - then seed just those:
just coverage-report https://your-endpoint/sparql
curl -sf https://your-endpoint/sparql \
--data-urlencode query@scripts/coverage-report.rq \
-H 'Accept: text/csv' | column -t -s,
Feed the namespaces it lists back to seed-public. It scans every IRI in the graph,
so run it against an offline copy or a dump - not a large production endpoint.
Pick a project name first — your Worker and R2 bucket are both named
label-cache-<project>, so each app gets its own isolated instance and two apps never share a
bucket. Set it once in .env (PROJECT=orders) or pass project=orders per run.
Sign up for Cloudflare (the free tier is plenty) or log into an existing account, then create the bucket and deploy the Worker:
pnpm wrangler login # sign up (free) or log in - opens the browser
# set name + bucket_name to label-cache-orders in wrangler.toml, then:
pnpm wrangler r2 bucket create label-cache-orders
pnpm wrangler deploy # prints your Cloudflare Worker URL → SEED_BASE
just login # sign up (free) or log in - opens the browser
just project=orders bucket # create bucket label-cache-orders (one-time)
just project=orders deploy # prints your Cloudflare Worker URL → SEED_BASE
Then create an R2 API token - dashboard → R2 →
Manage API Tokens → Create API Token with Object Read & Write. This
gives you R2_ACCESS_KEY_ID and R2_SECRET_ACCESS_KEY. You now have every
value the next step needs.
That's what the JSON-LD @context the Worker serves is for: it maps
schema:, skos:, dcat: etc. to their namespaces, so a consumer can
compact a full @id down to schema:name. Add your own prefixes there to abbreviate
your namespaces (see Customize the JSON-LD context below).
You now have every value from step 3 - put them in .env (copy env.example)
or export them in your shell. The generate & upload step reads them:
label-cache-<PROJECT>. Required by every Cloudflare recipe; pick one per app.https://label-cache-<PROJECT>.<subdomain>.workers.dev - printed by wrangler deploy in step 3.wrangler whoami.just bootstrap / just extract-labels to pull the labels.export SEED_BASE=https://label-cache-orders.<subdomain>.workers.dev
export R2_BUCKET=label-cache-orders
export CLOUDFLARE_ACCOUNT_ID=... R2_ACCESS_KEY_ID=... R2_SECRET_ACCESS_KEY=...
node scripts/ingest.mjs --input data.ttl # → dist/seed/manifest.ndjson
node scripts/upload-seed.mjs # → R2, over the S3 API
# PROJECT + SEED_BASE + R2_* come from your .env (see env.example)
just ingest # → dist/seed/manifest.ndjson
just project=orders upload # → R2, over the S3 API (sets R2_BUCKET)
ingest prints a per-namespace summary of the terms it wrote - no namespace needs
registering first.
Each object's keys (prefLabel, definition, …) are JSON-LD
terms, not baked-in fields - they're defined by the shared @context served at
SEED_BASE/context/labels-v1.json, which is what tells a consumer that prefLabel
expands to skos:prefLabel. To use your own vocabularies, edit that context. It's generated from
the CONTEXT_DOC object in scripts/ingest.mjs, then re-uploaded by
just upload.
So consumers can expand your keys. Inside CONTEXT_DOC["@context"]:
ex: "https://example.org/vocab#",
displayName: { "@id": "ex:displayName", "@container": "@language" },
A bare prefix lets consumers compact matching identifiers: an
@id of https://example.org/vocab#Widget abbreviates to ex:Widget. To
abbreviate a property key as well (not just an identifier), define a term like
displayName above.
To actually populate that key from your data, add its predicate IRI to
DEFAULT_LABEL_PREDS or DEFAULT_DESC_PREDS in scripts/ingest.mjs so ingest picks it up.
Then re-run just ingest && just upload. (If you also use the
local smoke test, mirror the change in src/routes/dev-seed.ts's CONTEXT_DOC.)
The same for both paths - once your labels are in R2, this is how any app reads them. The deploy and seed method doesn't matter.
The thin, zero-dependency
@rdf-label-cache/client is the
recommended path: it fires the lookups in parallel, prefers the browser cache, does the
untagged-then-language fallback, and picks a label - in a few lines. BASE is your
Cloudflare Worker URL and iris the IRIs your view mentions:
npm add @rdf-label-cache/client
import { createLabelClient } from "@rdf-label-cache/client";
// Construct once and reuse it - it keeps the connection warm and caches live.
const labels = createLabelClient({ base: BASE });
labels.preconnect(); // browser: warm the connection early
// Resolve every IRI your view mentions - parallel, deduped, edge-cached, with
// untagged-then-language fallback and a faithful label pick, all built in.
const resolved = await labels.resolveMany(iris);
// resolved: { "https://schema.org/Person": "Person", "https://schema.org/name": "name" }
Fire the lookups concurrently rather than serially (the library does). See the FAQ for the per-IRI cache rationale.
fetchThe library is thin because the call is simple: one edge-cached GET per IRI, then
pick a label from the JSON-LD. If you'd rather not add a dependency, it's a handful of lines. No
?lang= is the untagged label (labels/und/{iri}); add
&lang=xx for a language:
// Your preference order across predicate terms; first hit wins.
const ORDER = ["prefLabel", "label", "title", "name"];
const first = (v) => (Array.isArray(v) ? v[0] : v);
const pick = (doc) =>
ORDER.map((t) => first(doc[t]?.["@none"] ?? Object.values(doc[t] ?? {})[0])).find(Boolean) ?? null;
const resolved = Object.fromEntries(await Promise.all(iris.map(async (iri) => {
const res = await fetch(`${BASE}/label?iri=${encodeURIComponent(iri)}`);
return [iri, res.ok ? pick(await res.json()) : null];
})));
Keeping your graph in an n3 Store? Expand each label doc to RDF/JS quads
with the library's toQuads and add them straight in - no JSON-LD parser
(jsonld-streaming-parser and friends) needed. It caches the versioned
@context for you, so there's no per-doc context round-trip. Pass n3's
DataFactory for native quads; language tags are preserved ("Concept"@en):
import { DataFactory, Store } from "n3";
import { createLabelClient } from "@rdf-label-cache/client";
const store = new Store(/* your parsed graph */);
const labels = createLabelClient({ base: BASE });
const docs = await labels.documentMany(iris); // { iri: JSON-LD doc | null }
for (const doc of Object.values(docs)) {
if (doc) store.addQuads(await labels.toQuads(doc, { factory: DataFactory }));
}
// store now holds your data + every label as triples.
The library already does all of this; it matters when you go raw. Every
/label response is cached at two layers - the Cloudflare edge (shared across all your
users) and the browser - driven by Cache-Control (max-age for browsers,
s-maxage for the edge, plus stale-while-revalidate):
fetch - never cache: 'no-store' or
reload, which bypass both caches. Repeat lookups then return instantly, refreshed in the
background.iri|lang (the library's cache: "auto" does this).labels tag; returning browsers pick it up
within the hour.