npm package: @synchronized-studio/cmsassets-core
Description: Universal CMS integration foundation for Orinami — provider-aware request classification, URL rewriting, and fetch adapter.
Author: Synchronized Studio
Latest version: 0.4.10

# @synchronized-studio/cmsassets-core

Universal CMS integration layer for [Orinami](https://cmsassets.com).

Routes CMS API and asset requests through the Orinami edge proxy — adding edge caching, image optimization, and server-side token injection without changing your application logic.

Works in any JavaScript environment: Node.js 18+, browsers, Cloudflare Workers, Deno.

**Built-in providers:** Contentful, Prismic — plus any CMS via `createGenericProvider`

---

## Install

```bash
npm install @synchronized-studio/cmsassets-core
```

```bash
pnpm add @synchronized-studio/cmsassets-core
```

```bash
yarn add @synchronized-studio/cmsassets-core
```

---

## How it works

Orinami sits between your app and your CMS. Instead of calling Contentful or Prismic directly, requests go through your tenant's proxy endpoint:

```
Your app
  → Orinami edge (tenant.cmsassets.com)
    → Contentful / Prismic / ...
```

`@synchronized-studio/cmsassets-core` handles the URL rewriting at the transport layer. You configure it once and drop it into your CMS SDK — no other code changes needed.

---

## Quick start

### Contentful

```ts
import { createContentfulAdapter } from '@synchronized-studio/cmsassets-core'
import { createClient } from 'contentful'

const client = createClient({
  space: 'abc123xyz',
  accessToken: 'proxy-injected',  // any truthy string — real token is injected server-side by the proxy
  adapter: createContentfulAdapter({
    tenant: 'my-site',
    provider: 'contentful',
    providerConfig: { spaceId: 'abc123xyz' },
  }),
})

// All API calls go through the proxy automatically:
// https://cdn.contentful.com/spaces/abc123xyz/entries
// → https://my-site.cmsassets.com/~api/spaces/abc123xyz/entries
```

> **Why `createContentfulAdapter` instead of `createCmsAssetsFetch`?**
> The Contentful SDK uses Axios internally, which expects an adapter with a different signature than `fetch`. It also injects `Authorization` and `X-Contentful-User-Agent` headers that trigger CORS preflight in browsers. `createContentfulAdapter` handles both: it bridges Axios to fetch and strips the headers that would cause CORS issues. For direct fetch usage without the SDK, use `createCmsAssetsFetch`.

### Prismic

```ts
import { createCmsAssetsFetch } from '@synchronized-studio/cmsassets-core'
import * as prismic from '@prismicio/client'

const cmsFetch = createCmsAssetsFetch({
  tenant: 'my-site',
  provider: 'prismic',
})

const client = prismic.createClient('my-repo', {
  fetch: cmsFetch,
})

// All API calls go through the proxy automatically:
// https://my-repo.cdn.prismic.io/api/v2/documents/search?ref=...
// → https://my-site.cmsassets.com/~api/api/v2/documents/search?ref=...
```

### Any CMS via generic provider

```ts
import { createGenericProvider, createCmsAssetsFetch } from '@synchronized-studio/cmsassets-core'

const storyblok = createGenericProvider({
  id: 'storyblok',
  apiHosts: ['api.storyblok.com'],
  assetHosts: ['a.storyblok.com'],
})

const cmsFetch = createCmsAssetsFetch({
  tenant: 'my-site',
  provider: storyblok,   // pass ProviderDefinition directly — no registry needed
})

// https://api.storyblok.com/v2/stories → https://my-site.cmsassets.com/~api/v2/stories
// https://a.storyblok.com/f/123/hero.jpg → https://my-site.cmsassets.com/f/123/hero.jpg
```

---

## URL rewriting without fetch

Use `createUrlRewriter` when you need to transform URLs without intercepting fetch — useful for server-side response body processing, logging, or building custom adapters.

```ts
import { createUrlRewriter } from '@synchronized-studio/cmsassets-core'

const rewrite = createUrlRewriter({
  tenant: 'my-site',
  provider: 'contentful',
  providerConfig: { spaceId: 'abc123xyz' },
})

// API URL
rewrite('https://cdn.contentful.com/spaces/abc123xyz/entries?locale=en')
// → { url: 'https://my-site.cmsassets.com/~api/spaces/abc123xyz/entries?locale=en', kind: 'api', rewritten: true }

// Asset URL — image optimization params are preserved
rewrite('https://images.ctfassets.net/abc123xyz/assetId/token/hero.jpg?w=1200&fm=webp')
// → { url: 'https://my-site.cmsassets.com/assetId/token/hero.jpg?w=1200&fm=webp', kind: 'asset', rewritten: true }

// Non-CMS URL — passes through unchanged
rewrite('https://fonts.googleapis.com/css2?family=Inter')
// → { url: 'https://fonts.googleapis.com/css2?family=Inter', kind: 'unknown', rewritten: false }
```

---

## API reference

### `createCmsAssetsFetch(options)`

Returns a `fetch`-compatible function that rewrites CMS URLs through the proxy. Safe to use as a global fetch replacement — non-CMS requests pass through unchanged.

```ts
const cmsFetch = createCmsAssetsFetch({
  tenant: 'my-site',          // required — Orinami tenant slug
  provider: 'contentful',     // required — provider id string OR a ProviderDefinition object
  providerConfig: {           // optional — provider-specific config
    spaceId: 'abc123xyz',     //   Contentful: spaceId
  },
  enableApiProxy: true,       // optional — rewrite API requests (default: true)
  enableAssetProxy: true,     // optional — rewrite asset requests (default: true)
  registry: myRegistry,       // optional — custom provider registry (used when provider is a string)
})
```

**Using `enableApiProxy` / `enableAssetProxy`:**

```ts
// Asset proxy only — API calls go directly to Contentful
const cmsFetch = createCmsAssetsFetch({
  tenant: 'my-site',
  provider: 'contentful',
  enableApiProxy: false,
})

// API proxy only — images are not proxied
const cmsFetch = createCmsAssetsFetch({
  tenant: 'my-site',
  provider: 'prismic',
  enableAssetProxy: false,
})
```

---

### `createContentfulAdapter(options)`

Returns an Axios-compatible adapter function for the Contentful JS SDK. Internally creates a `cmsFetch` for URL rewriting and bridges the Axios adapter interface to fetch. Automatically strips `Authorization` and `X-Contentful-User-Agent` headers to prevent CORS preflight requests.

```ts
import { createContentfulAdapter } from '@synchronized-studio/cmsassets-core'
import { createClient } from 'contentful'

const client = createClient({
  space: 'abc123xyz',
  accessToken: 'proxy-injected',
  adapter: createContentfulAdapter({
    tenant: 'my-site',
    provider: 'contentful',
    providerConfig: { spaceId: 'abc123xyz' },
  }),
})
```

**Options:** Same as `createCmsAssetsFetch`, plus:

| Option | Type | Description |
|--------|------|-------------|
| `wrapFetch` | `(baseFetch) => fetch` | Optional middleware to wrap the internal fetch before use (e.g. cache bypass) |

```ts
// With preview cache bypass:
const adapter = createContentfulAdapter({
  tenant: 'my-site',
  provider: 'contentful',
  providerConfig: { spaceId: 'abc123xyz' },
  wrapFetch: (baseFetch) => withCacheBypass(baseFetch, () => isPreview),
})
```

---

### `createUrlRewriter(options)`

Returns a bound `(url: string | URL) => RewriteResult` function. Same options as `createCmsAssetsFetch`.

```ts
import { createUrlRewriter } from '@synchronized-studio/cmsassets-core'

const rewrite = createUrlRewriter({
  tenant: 'my-site',
  provider: 'prismic',
})

const { url, kind, rewritten } = rewrite('https://images.prismic.io/my-repo/hero.jpg?w=800&fm=webp')
// url:       'https://my-site.cmsassets.com/my-repo/hero.jpg?w=800&fm=webp'
// kind:      'asset'
// rewritten: true
```

---

### `classifyUrl(url, provider)`

Classify a URL as `'api'`, `'asset'`, or `'unknown'` based on its hostname.

```ts
import { classifyUrl, contentful, prismic } from '@synchronized-studio/cmsassets-core'

classifyUrl('https://cdn.contentful.com/spaces/abc/entries', contentful)
// → 'api'

classifyUrl('https://images.ctfassets.net/abc/assetId/token/img.jpg', contentful)
// → 'asset'

classifyUrl('https://my-repo.cdn.prismic.io/api/v2/documents/search', prismic)
// → 'api'

classifyUrl('https://images.prismic.io/my-repo/photo.jpg', prismic)
// → 'asset'

classifyUrl('https://example.com/logo.svg', contentful)
// → 'unknown'
```

---

### `rewriteUrl(url, kind, provider, tenant, config?)`

Low-level rewrite — takes a pre-classified URL and returns a `RewriteResult`.

```ts
import { classifyUrl, rewriteUrl, contentful } from '@synchronized-studio/cmsassets-core'

const url = new URL('https://cdn.contentful.com/spaces/abc/entries?locale=en')
const kind = classifyUrl(url, contentful)
const result = rewriteUrl(url, kind, contentful, 'my-site', { spaceId: 'abc' })

// result.url       → 'https://my-site.cmsassets.com/~api/spaces/abc/entries?locale=en'
// result.kind      → 'api'
// result.rewritten → true
```

---

### `ProviderRegistry`

Register custom or extended provider definitions.

```ts
import { ProviderRegistry, contentful, prismic } from '@synchronized-studio/cmsassets-core'

// Create a custom registry
const registry = new ProviderRegistry()
  .register(contentful)
  .register(prismic)

// Use it with the fetch adapter
const cmsFetch = createCmsAssetsFetch({
  tenant: 'my-site',
  provider: 'contentful',
  registry,
})
```

The `defaultRegistry` is a shared singleton pre-populated with `contentful` and `prismic`. You only need a custom registry if you want to override a built-in provider or register your own.

---

### `createGenericProvider(config)`

Create a `ProviderDefinition` from a config object — use any CMS or API origin without writing a custom provider from scratch.

```ts
import { createGenericProvider } from '@synchronized-studio/cmsassets-core'

const provider = createGenericProvider({
  id: 'storyblok',
  apiHosts: ['api.storyblok.com'],        // exact or wildcard (e.g. '*.api.example.com')
  assetHosts: ['a.storyblok.com'],

  // Optional — reserved for future Worker-side auth injection
  auth: {
    header: 'Authorization',
    format: 'Bearer {token}',
  },

  // Optional — reserved for future proxy preview bypass support
  previewBypass: {
    queryParams: ['preview', 'draft'],
  },
})
```

URL rewriting follows standard conventions:
```
API:   https://api.storyblok.com/v2/stories  → https://{tenant}.cmsassets.com/~api/v2/stories
Asset: https://a.storyblok.com/f/123/img.jpg → https://{tenant}.cmsassets.com/f/123/img.jpg
```

---

### `API_PREFIX`

The `/~api` path prefix used by the Orinami API proxy. Exported for use in custom integrations.

```ts
import { API_PREFIX } from '@synchronized-studio/cmsassets-core'

console.log(API_PREFIX) // '/~api'
```

---

## Providers

### Contentful

| Kind | Hosts |
|---|---|
| API | `cdn.contentful.com`, `preview.contentful.com` |
| Assets | `images.ctfassets.net`, `videos.ctfassets.net`, `assets.ctfassets.net`, `downloads.ctfassets.net` |

**`providerConfig.spaceId`** — Contentful space ID. Required for correct asset path stripping. When provided, the space ID segment is removed from the proxy URL path (matching the proxy worker's configured origin).

```ts
// Without spaceId:
// https://images.ctfassets.net/abc/assetId/token/img.jpg
// → https://my-site.cmsassets.com/abc/assetId/token/img.jpg

// With spaceId: 'abc':
// https://images.ctfassets.net/abc/assetId/token/img.jpg
// → https://my-site.cmsassets.com/assetId/token/img.jpg  ← spaceId stripped
```

---

### Prismic

| Kind | Hosts |
|---|---|
| API | `*.cdn.prismic.io` (wildcard — matches any repo subdomain) |
| Assets | `images.prismic.io`, `prismic-io.imgix.net` |

Prismic image optimization params (`w`, `h`, `q`, `fm`, `auto`, `fit`) are preserved end-to-end. The proxy forwards them to `images.prismic.io` (Imgix CDN) where they are processed.

```ts
// Image optimization is preserved:
// https://images.prismic.io/my-repo/hero.jpg?auto=format,compress&w=920&fm=webp&q=80
// → https://my-site.cmsassets.com/my-repo/hero.jpg?auto=format,compress&w=920&fm=webp&q=80
```

Prismic's `ref` parameter changes on every content publish, making each published version a distinct cache key — no manual cache invalidation needed.

---

## TypeScript

All types are exported from the package root:

```ts
import type {
  RequestKind,           // 'api' | 'asset' | 'unknown'
  ProviderDefinition,    // shape of a provider
  ProviderConfig,        // Record<string, string | undefined>
  RewriteResult,         // { url, kind, rewritten }
  CmsAssetsFetchOptions, // options for createCmsAssetsFetch
  UrlRewriterOptions,    // options for createUrlRewriter
  GenericProviderConfig, // config shape for createGenericProvider
} from '@synchronized-studio/cmsassets-core'
```

---

## Custom providers

### Using `createGenericProvider` (recommended)

```ts
import { createGenericProvider, createCmsAssetsFetch } from '@synchronized-studio/cmsassets-core'

const sanity = createGenericProvider({
  id: 'sanity',
  apiHosts: ['*.api.sanity.io'],
  assetHosts: ['cdn.sanity.io'],
  auth: { header: 'Authorization', format: 'Bearer {token}' },
})

// Pass directly — no registry needed
const cmsFetch = createCmsAssetsFetch({ tenant: 'my-site', provider: sanity })

// Or register and use by id string
import { defaultRegistry } from '@synchronized-studio/cmsassets-core'
defaultRegistry.register(sanity)
const cmsFetch2 = createCmsAssetsFetch({ tenant: 'my-site', provider: 'sanity' })
```

### Using `ProviderDefinition` directly (advanced)

For full control over rewrite logic:

```ts
import { ProviderDefinition, defaultRegistry } from '@synchronized-studio/cmsassets-core'

const sanity: ProviderDefinition = {
  id: 'sanity',
  apiHosts: ['*.api.sanity.io'],
  assetHosts: ['cdn.sanity.io'],
  rewriteApiUrl(url, tenant) {
    return `https://${tenant}.cmsassets.com/~api${url.pathname}${url.search}`
  },
  rewriteAssetUrl(url, tenant) {
    return `https://${tenant}.cmsassets.com${url.pathname}${url.search}`
  },
}

defaultRegistry.register(sanity)
```

---

## Requirements

- Node.js 18+ (or any runtime with the Fetch API)
- No dependencies