Home

Getting started

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.

Fastest

Deploy button + Action seed

No clone, no local Node. Best for vocabularies and small / modest label sets.

Full pipeline

Clone + pipeline

Large dumps, and full control over concurrency, credentials, and multiple projects.

Fast path — deploy button + Action seed

No clone, no local Node. Best for vocabularies and small / modest label sets; re-running the seed is an upsert, not a mirror.

1

Deploy

The Deploy to Cloudflare button provisions the Worker + R2 bucket and clones this repo into your GitHub, with Workers Builds wired up so later pushes redeploy.

2

Add the seed workflow

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.

3

Add credentials

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:

SEED_BASE
Your Worker's URL. Dashboard → Workers & Pages → your Worker → copy the *.workers.dev URL.
R2_BUCKET
The bucket the deploy created for you. Dashboard → R2 → your bucket's name.
CLOUDFLARE_ACCOUNT_ID
Dashboard → R2Overview - the account ID is in the right sidebar (or run wrangler whoami).
R2_ACCESS_KEY_ID
R2_SECRET_ACCESS_KEY
An R2 API token: dashboard → R2Manage R2 API TokensCreate API Token with Object Read & Write. The secret is shown only once.
PURGE_TOKEN
A value you choose (e.g. 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.
4

Add labels

Drop RDF into the labels/ folder and commit. A full instance-data dump is fine - only label and description triples are extracted.

5

Seed

Actions → seed-labels → Run workflow (tick include common ontology labels for the bundled vocabularies).

Full pipeline — clone + pipeline, for larger datasets

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.

Commands:
Using npm or bun instead of pnpm

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 installnpm 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.

1

Clone & install

git clone https://github.com/recalcitrantsupplant/rdf-label-cache && cd rdf-label-cache
pnpm install
2

Dump the annotation properties you want cached

Labels for your instance data

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 query is only a starting point

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.

Show the example label-extraction query
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.

Why two steps?

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.

Labels for the ontologies you use

Cover those public-vocabulary terms - schema:name, skos:Concept, rdfs:label - two ways:

Public vocabularies:

Seed the public vocabulary labels

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
How much does seeding all of them cost?

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.

Find exactly which vocabularies you're missing

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.

3

Set up Cloudflare & get credentials

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 → R2Manage API TokensCreate 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.

Want short names instead of full IRIs in responses?

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).

4

Configuration

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:

PROJECT
Names your Worker + bucket, both label-cache-<PROJECT>. Required by every Cloudflare recipe; pick one per app.
SEED_BASE
Your deployed Cloudflare Worker URL, e.g. https://label-cache-<PROJECT>.<subdomain>.workers.dev - printed by wrangler deploy in step 3.
CLOUDFLARE_ACCOUNT_ID
Your Cloudflare account id - right sidebar of the dashboard, or run wrangler whoami.
R2_ACCESS_KEY_ID
R2_SECRET_ACCESS_KEY
The R2 API token you created in step 3 (Object Read & Write). The secret is shown only once.
SPARQL_ENDPOINT (optional)
Your triplestore's query endpoint - used by just bootstrap / just extract-labels to pull the labels.
5

Generate & upload your label objects

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.

Customize the JSON-LD context - optional: your own vocabularies

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.

Add a namespace prefix & term

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.

Also cache a new annotation property

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.)

Consume from your app

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 client library

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.

Without a dependency — plain fetch

The 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];
})));

Already using n3 — labels as RDF

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.

Get the most from the cache

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):

  • Use the default fetch - never cache: 'no-store' or reload, which bypass both caches. Repeat lookups then return instantly, refreshed in the background.
  • Browser apps need no label cache of their own - the HTTP cache already de-dupes across renders; just an in-flight de-dupe so a single render fires each IRI once (the library does this).
  • Server-side callers (Node, another Worker) share no HTTP cache - keep a small in-memory LRU keyed by iri|lang (the library's cache: "auto" does this).
  • Changed a label? Purge the labels tag; returning browsers pick it up within the hour.

Try the demo  ·  Why RDF Label Cache?  ·  Source & docs