Skip to content

Latest commit

 

History

History
547 lines (418 loc) · 26.7 KB

File metadata and controls

547 lines (418 loc) · 26.7 KB

@localess/client Reference

Core JavaScript/TypeScript SDK for the Localess headless CMS.

Server-side only — never import it in browser code. A secret API token must never reach the browser; use the client in Next.js Server Components, API routes, getServerSideProps, Remix loaders, etc. Localess also issues public tokens (read-only, published content and translations only) that are safe client-side, but only through a framework package's client-side primitives (currently @localess/react, @localess/angular, @localess/vue, and @localess/svelte) — never by importing @localess/client directly in the browser. @localess/cli and @localess/astro are still secret-token-only. → ADR 001

Zero external dependencies — its only dependencies are the in-monorepo @localess/model package, whose data-model types it re-exports unchanged, and @localess/live-preview, used solely for the deprecated Visual Editor re-exports. Node.js >= 24.0.0. → ADR 002, ADR 009, ADR 013

Installation

npm install @localess/client

Initialization

import { localessClient } from "@localess/client";

const client = localessClient({
  origin: 'https://my-localess.web.app',  // Full URL with protocol (required)
  spaceId: 'YOUR_SPACE_ID',               // From Space settings (required)
  token: 'YOUR_API_TOKEN',                // API token — NEVER expose client-side (required)
  version: 'draft',                       // undefined = published (default), 'draft' for preview
  debug: false,                           // Logs requests; default: false
  cacheTTL: 300,                          // Cache TTL in seconds; false to disable; default: 300 (5 min)
  timeoutMs: 15000,                       // Per-attempt timeout in ms; false to disable; default: 15000
  retry: { attempts: 3 },                 // false to disable; default: 3 attempts, 300ms base, 5s cap
  fetch: myFetch,                         // Replacement for the global fetch; default: global fetch
  cache: myCache,                         // Your own ICache; overrides cacheTTL
  fetchInit: { next: { revalidate: 60 } },// Framework fetch options; bypasses the client cache
});

Store credentials in environment variables:

LOCALESS_ORIGIN=https://my-localess.web.app
LOCALESS_SPACE_ID=your-space-id
LOCALESS_TOKEN=your-api-token

Resilience

Requests retry, time out, and can be cancelled. Defaults are on — a single transient failure should not fail a build.

Option Default Notes
timeoutMs 15000 Per attempt, not per call. false waits indefinitely.
retry.attempts 3 Includes the first request. 1 or retry: false disables retrying.
retry.baseDelayMs 300 Base for exponential backoff.
retry.maxDelayMs 5000 Caps any single delay, including a Retry-After the server asks for.
retry.retryStatuses [408, 429, 500, 502, 503, 504] Every other 4xx throws immediately.
fetch global fetch Injectable, for instrumentation or a runtime-specific implementation.

What is retried. Network failures and the statuses above. A 401 or 403 is thrown straight away — a bad token will not fix itself, and retrying only delays the error you need to see. All four fetching methods are GETs, so retrying is always idempotent.

Backoff uses full jitter — each delay is drawn uniformly from [0, cap] rather than being fixed. That matters when a batch of static-generation workers all start against a cold origin: equal jitter would still leave them retrying in step.

Retry-After wins. On a 429 or 503 the server's requested delay is used instead of the computed backoff, clamped to maxDelayMs. Both delta-seconds and HTTP-date forms are accepted; an unparseable value falls back to backoff.

Worst-case latency is roughly attempts × timeoutMs plus backoff, since each attempt gets a fresh timeout. With the defaults that is about 45 seconds against a completely dead origin.

Cancellation

Every fetching method accepts a signal:

const controller = new AbortController();
const content = await client.getContentBySlug('home', { signal: controller.signal });
controller.abort();

It is composed with the client's own timeout, so whichever fires first wins. Aborting through your signal is treated as your decision and is never retried — unlike a timeout, which is a transient failure and is.

Diagnosing failures

Both error types carry attempts, and the rendered error box gains an Attempts row when a request was retried, so a log line tells you whether the client gave up after trying:

catch (error) {
  if (error instanceof LocalessApiError) {
    console.log(error.status, error.attempts);
  }
}

Caching

Two independent layers, and only one applies to a given request.

The client's own cache

In-memory, per client instance, 5-minute TTL by default. Configure with cacheTTL (seconds, or false to disable), or replace it entirely:

const client = localessClient({
  ...options,
  cache: {
    async has(key) { return (await redis.exists(key)) === 1; },
    async get(key) { return JSON.parse(await redis.get(key)); },
    async set(key, value) { await redis.set(key, JSON.stringify(value), { EX: 300 }); },
  },
});

ICache methods may return promises. A supplied cache owns expiry, so cacheTTL is ignored.

Cache keys exclude the token, so a shared cache instance is shared across tokens. That is safe for tokens with equal permissions — the API returns the same bytes for a given key, and draft-ness is part of the key via version. Do not share one cache instance between tokens with different permissions: a token lacking CONTENT_DRAFT would get a hit on a draft entry another client stored, rather than the 403 the API would return. Per-client caches are unaffected.

Framework caching, and the bypass rule

fetchInit is merged into every fetch call, for frameworks that extend it:

const client = localessClient({ ...options, fetchInit: { next: { revalidate: 60 } } });

// or per call
await client.getContentBySlug('home', { fetchInit: { next: { revalidate: 5 } } });

A request carrying fetchInit bypasses the client cache entirely — neither read nor written. Two caching layers over one call is how content survives a revalidateTag() and looks like a bug, so when the framework has been asked to cache a request, it owns that request.

Cache tags

When next is present and you have not named your own tags, the client fills them in:

localess                            every request
localess:space:<spaceId>            everything in one space
localess:links                      the link tree
localess:content:<contentId>        one document by id
localess:slug:<fullSlug>            one document by slug
localess:translations:<locale>      one locale's translations

So revalidateTag('localess:slug:home') works with no bookkeeping. An explicit tags array replaces the generated ones rather than merging, so you can opt out.

localessCacheTags(spaceId, target) is exported, so a webhook handler can produce the same strings from the other direction.

API Methods

getContentBySlug<T>(slug, params?)

const content = await client.getContentBySlug<Page>('home', {
  locale: 'en',
  resolveReference: true,  // Populate `references` (one level only)
  resolveLink: true,        // Populate `links` with content metadata
  resolveAsset: true,       // Populate `assets` with asset metadata
  version: 'draft',         // Override client default per-request
});
// content.data is typed as Page

getContentById<T>(id, params?)

const content = await client.getContentById<Article>('content-id-here', {
  locale: 'en',
  resolveReference: true,
});

getLinks(params?)

const links = await client.getLinks({
  kind: 'DOCUMENT',          // 'DOCUMENT' | 'FOLDER'
  parentSlug: 'blog',        // Filter to children of a slug
  excludeChildren: false,    // Exclude nested sub-slugs
});
// links: { [id: string]: ContentMetadata }

getTranslations(locale, params?)

const t = await client.getTranslations('en', { version: 'draft' }); // params optional, overrides client default
// t: { [key: string]: string }
// Usage: t['common.submit'] => 'Submit'

assetLink(asset, params?)

// Basic — no transform
const url = client.assetLink(content.data.image);
// Returns: https://my-localess.web.app/api/v1/spaces/{spaceId}/assets/{uri}

// With transform params
const url = client.assetLink(content.data.image, { w: 800, h: 600, f: 'webp', q: 90 });

// Accepts ContentAsset or raw URI string
const url = client.assetLink('my-image.png', { w: 400 });

assetOriginalLink(asset) / assetDownloadLink(asset)

The stored bytes as an attachment, so the browser saves rather than displays them. Its own route rather than a transform parameter — the response never enters the image pipeline, so it takes no w/h/q/f/fit, and passing one is rejected by the API with a 400.

const url = client.assetDownloadLink(content.data.file);
// Returns: https://my-localess.web.app/api/v1/spaces/{spaceId}/assets/{uri}/download

// Accepts a ContentAsset or a raw URI string
const url = client.assetDownloadLink('brochure.pdf');
const url = client.assetOriginalLink(content.data.image);
// Returns: https://my-localess.web.app/api/v1/spaces/{spaceId}/assets/{uri}/original

assetLink(asset) does not return the uploaded file. A still raster is re-encoded at its format's default quality even with no params, so a bare asset URL is a rendition — a q95 camera export measured 587 KB and came back 219 KB. assetOriginalLink is the only way to get the uploaded bytes.

Removed in v4. assetLink(asset, { download: true }) and assetLink(asset, { f: 'original' }) were the old spellings; both are now rejected by the API with a 400. To resize without converting, name the source format explicitly — { w: 400, f: 'jpeg' }.

syncScriptUrl()

const url = client.syncScriptUrl();
// https://my-localess.web.app/scripts/sync-v1.js — for manual <script> injection

findLink(links, link)

import { findLink } from "@localess/client";

const href = findLink(content.links, data.cta);
// type 'content' → '/' + links[uri].fullSlug ('/not-found' when missing or links is undefined)
// type 'url'     → the raw uri

Content Fetch Parameters

Parameter Type Default Description
version 'draft' | undefined undefined 'draft' for preview, omit for published
locale string — ISO 639-1 code: 'en', 'de', etc.
resolveReference boolean false Populate references — one level only
resolveLink boolean false Populate links with content metadata
resolveAsset boolean false Populate assets with asset metadata

What resolution does and does not do

All three flags are all-or-nothing — every id the document uses is resolved, and individual fields cannot be selected. There is no depth option.

resolveReference resolves one level. Each entry carries metadata, locale and data, with none of its own references/links/assets — raw ids are an internal storage concern and are stripped, exactly as they are for the top-level document. References values are typed as Content (it is a shared type, reused elsewhere), so those three fields are simply always undefined here.

Nothing is lost by that: the edges live in data. A REFERENCE field value is { kind: 'REFERENCE', uri }, so you follow one by looking its uri up in the same map:

const authorId = content.data?.author?.uri;
const author = authorId ? content.references?.[authorId] : undefined;

resolveLink and resolveAsset are terminal: they return metadata only, with nothing nested. resolveAsset returns no URL — build one with assetLink().

Partial failure is silent. If a referenced document, linked document, or asset has been deleted, it is omitted from the map and the request still succeeds. A missing key therefore means "could not resolve", not "not used" — compare against the document's own id list if you need to tell the two apart.

All three params types also accept fetchInit (see Framework caching) and signal (see Cancellation).

getTranslations takes TranslationFetchParams (version, plus fetchInit/signal). getLinks takes LinksFetchParams:

Parameter Type Description
kind 'DOCUMENT' | 'FOLDER' Filter by content kind (all when omitted)
parentSlug string Filter to content under this parent slug (e.g. 'legal/policy')
excludeChildren boolean true → exclude nested sub-slugs

Error Handling

getLinks, getContentBySlug, getContentById, and getTranslations throw instead of returning empty data on failure.

  • A non-2xx HTTP response rejects with a LocalessApiError — status, statusText, url (token redacted), body (the API's parsed response body, if any), and hint (a status-specific, human-readable explanation of the likely cause; 401/403 link to {origin}/features/spaces/{spaceId}/settings/tokens, and any message/status/code/details fields in the body are folded in).
  • Every caller-supplied value is URL-encoded (slugs segment by segment, keeping /), so a slug taken from a visitor's URL can't add query parameters or drop the token. A slug with an empty, . or .. segment — which no document can have, and which URL would otherwise resolve onto a different endpoint — is rejected with a LocalessApiError of status 404 without a request, so it reaches your not-found handling.
  • A failure to reach the API at all (DNS, connection refused, TLS, etc.) rejects with a LocalessNetworkError — origin, url (token redacted), hint, and cause (the underlying error).
  • Both are also logged via console.error as a boxed summary before being thrown (ANSI colour only in a TTY, and never when NO_COLOR or NEXT_RUNTIME is set).
import { LocalessApiError, LocalessNetworkError } from "@localess/client";

try {
  const content = await client.getContentBySlug('home');
} catch (error) {
  if (error instanceof LocalessApiError) {
    console.error(`${error.status} ${error.statusText}: ${error.hint}`);
  } else if (error instanceof LocalessNetworkError) {
    console.error(`Could not reach ${error.origin}: ${error.hint}`);
  } else {
    throw error;
  }
}

Caching

Default: in-memory TTL cache, 5 minutes (300 seconds). Cache key = request URL with the token param removed and the remaining params sorted. Instance-bound — one cache per localessClient() call (unless you pass a shared cache). The implementations (TTLCache, NoCache, Cache) and the ICache interface are exported. → ADR 003

localessClient({ cacheTTL: 60 })    // 1 min — frequently updated content
localessClient({ cacheTTL: 3600 })  // 1 hour — rarely updated content
localessClient({ cacheTTL: false }) // Disabled — always fresh (use in draft/preview mode)

Visual Editor Helpers

Deprecated. loadLocalessSync, localessEditable, localessEditableField, isBrowser/isServer/isIframe and the sync event types now live in @localess/live-preview; @localess/client only re-exports them for back-compat and will drop them in a future major. Import from @localess/live-preview (or your framework package) instead. → ADR 013

loadLocalessSync(origin)

Injects the Localess Visual Editor sync script ({origin}/scripts/sync-v1.js) into <head>. Returns Promise<void> — resolves on load, rejects on load error. Resolves immediately without injecting on the server, when not inside an iframe (with a console.warn), or when window.localess already exists; concurrent calls share one promise.

import { loadLocalessSync } from "@localess/client";
await loadLocalessSync('https://my-localess.web.app');

localessEditable(content) and localessEditableField<T>(fieldName)

Add attributes recognized by the Localess Visual Editor for click-to-edit.

import { localessEditable, localessEditableField } from "@localess/client";

<section {...localessEditable(data)}>
  {/* Adds: data-ll-id (from _id), data-ll-schema (from _schema) */}
  <h1 {...localessEditableField<Page>('title')}>
    {/* Adds: data-ll-field="title" */}
    {data.title}
  </h1>
</section>

Editor Events

// `content` is the Content object fetched for this page
if (window.localess) {
  // `event` is narrowed to the subscribed variant(s) via EventToAppOf<T> — no manual type check needed
  window.localess.on(['input', 'change'], (event) => {
    if (event.documentId === content.id) setPageData(event.data);
  });
  // Shorthand for the same subscription
  const unsubscribe = window.localess.onChange((event) => {
    if (event.documentId === content.id) setPageData(event.data);
  });
  // `on`/`onChange` return an unsubscribe function; `off(event, callback)` does the same
  unsubscribe();
}

Framework SDKs filter for you: localessSyncOnDocument(documentId, callback) (React, Vue, Svelte) or LocalessSyncService.onDocument (Angular) calls back with one document's edited data.

Event Payload When
input { type: 'input', documentId: string, data: any } User typing in a field (real-time)
change { type: 'change', documentId: string, data: any } Field value confirmed
save { type: 'save', documentId: string } Content saved
publish { type: 'publish', documentId: string } Content published
unpublish { type: 'unpublish', documentId: string } Content unpublished
pong { type: 'pong' } Editor heartbeat response
enterSchema { type: 'enterSchema', id, schema, field?, root? } Editor cursor enters a schema block; root is true for the document root
hoverSchema { type: 'hoverSchema', id, schema, field? } Editor cursor hovers a schema block
leaveSchema { type: 'leaveSchema' } Editor cursor leaves a schema block

documentId is the id of the Content being edited (as returned by the Localess API). A page that renders several documents receives every document's events, so custom handlers should compare event.documentId to their content's id before applying event.data.

Asset Transform Parameters

Param Type Description
w number Width in px. With h → governed by fit. Without → proportional scale.
h number Height in px. With w → governed by fit. Without → proportional scale.
q number (1–100) Output quality. Omit it and each encoder uses its own default (JPEG/WebP 80, AVIF 50); PNG ignores it.
f 'webp' | 'jpeg' | 'png' | 'avif' Convert to this output format. Nothing converts without it — recommended for cutting transfer size.
thumbnail boolean true → extract first frame from animated WebP/GIF or video (via FFmpeg).

Omitting every parameter still re-encodes a still raster at its format's default quality. For the uploaded bytes use assetOriginalLink, or assetDownloadLink to force a save. download and f: 'original' were removed in v4.

SVG files are always passed through unchanged — w, h, f are ignored for SVG.

fit — controlling the w+h crop

Only applied when both w and h are given; a single dimension always preserves the aspect ratio. Examples show a 200×100 source into a 50×50 box.

fit Behaviour 200×100 → 50×50
cover (API default) Fill the box, crop the overflow 50×50, sides cropped
contain Fit inside the box, pad to the exact box 50×50, padded
inside Shrink to fit inside the box, no pad, no crop 50×25
outside Cover the box without cropping; may exceed it 100×50
fill Stretch to the exact box, aspect ratio not preserved 50×50, distorted

inside is usually what a thumbnail wants. The SDK applies no client-side default — omitting fit leaves the API default (cover) in force, so existing URLs are byte-identical.

An unrecognised f or fit is rejected by the API with 400. Requires a Localess deployment with asset fit support; older deployments ignore the parameter.

Environment Utilities

Deprecated re-exports from @localess/live-preview — see Visual Editor Helpers.

import { isBrowser, isServer, isIframe } from "@localess/client";

isBrowser()  // true if window is defined
isServer()   // true if window is undefined
isIframe()   // true if running inside an iframe (browser only)

Key Types

All data-model types are defined in @localess/model and re-exported by @localess/client; the full list is in docs/model.md.

// Content response wrapper
interface Content<T extends ContentData> extends ContentMetadata {
  locale: string; // locale actually served, after any fallback
  data?: T;
  links?: Links;
  references?: References;
  assets?: Assets; // Populated when resolveAsset: true
}

// References reuses Content. What a value carries depends on the endpoint —
// for resolveReference the three collections are always absent. See above.

interface ContentMetadata {
  id: string; name: string; kind: 'FOLDER' | 'DOCUMENT';
  slug: string; fullSlug: string; parentSlug: string;
  publishedAt?: string; createdAt: string; updatedAt: string;
}

interface Assets { [id: string]: AssetMetadata }
interface AssetMetadata {
  id: string; name: string; extension: string; type: string; alt?: string;
  width?: number; height?: number; size: number; duration?: number;
}

// Base fields every content data object has
interface ContentDataSchema {
  _id: string;
  _schema: string;
}

interface ContentAsset    { kind: 'ASSET'; uri: string }
interface ContentLink     { kind: 'LINK'; type: 'url' | 'content'; target: '_blank' | '_self'; uri: string }
interface ContentRichText { type?: string; content?: ContentRichText[] }  // Tiptap JSON
interface ContentReference { kind: 'REFERENCE'; uri: string }

interface Links        { [contentId: string]: ContentMetadata }
interface References   { [contentId: string]: Content }
interface Translations { [key: string]: string }

interface Locale { id: string; name: string }
interface Space  { id: string; name: string; locales: Locale[]; localeFallback: Locale; createdAt: string; updatedAt: string }

Exports Reference

export { localessClient }
export { LocalessApiError }
export { LocalessNetworkError }
export { buildAssetQueryString }
export { findLink }
export { Cache, NoCache, TTLCache }
export { localessCacheTags, LOCALESS_CACHE_TAG }
export {
  normalizeComponentKey, createComponentIndex, formatComponentKeyCollisions,
  splitComponentWords, DEFAULT_COMPONENT_NAMING,
} // ADR 012 — shared by the framework packages
export type {
  LocalessClient, LocalessClientOptions, LocalessFetchInit, LocalessRetryOptions,
  ContentFetchParams, LinksFetchParams, TranslationFetchParams,
  ICache, LocalessCacheTarget,
  ComponentNaming, ComponentNamingStrategy, ComponentIndex, ComponentKeyCollision,
}
// Deprecated re-exports from @localess/live-preview (ADR 013):
export { localessEditable, localessEditableField }
export { loadLocalessSync }
export { isBrowser, isServer, isIframe }
export type { LocalessSync, EventToApp, EventToAppOf, EventCallback, EventToAppType }
// Re-exported from @localess/model (everything it exports):
export type {
  Content, ContentData, ContentDataSchema, ContentDataField,
  ContentMetadata, ContentAsset, ContentLink,
  ContentRichText, ContentReference,
  Links, References, Assets, AssetMetadata, AssetTransformParams,
  Translations, Locale, Space,
  SchemaType, SchemaFieldKind, AssetFileType, SchemaEnumValue,
  SchemaFieldBase, SchemaField /* + the 18 SchemaField* interfaces */,
  SchemaComponentExport, SchemaEnumExport, SchemaExport,
}

window.localess?: LocalessSync is also declared on the global Window interface.

Common Mistakes

  • Importing in browser code. Any file that runs in the browser (React Client Component, useEffect, client-side bundle) must never import @localess/client. Use @localess/react hooks or RSC patterns instead. The only browser-safe exports are the token-free helpers (isBrowser, isServer, isIframe, loadLocalessSync, localessEditable, localessEditableField) and the sync event types.
  • Adding production dependencies. The package has zero external deps by design. Never add anything beyond @localess/model and @localess/live-preview to dependencies in packages/client/package.json.
  • Using milliseconds for cacheTTL. cacheTTL is in seconds (300 = 5 minutes), not milliseconds.