Skip to main content
Glama
README.md
# assets-cdn

> Content-hashed asset uploads for any S3-compatible CDN — with typed access, safe partial uploads, and deferred deletion.

[![CI](https://github.com/chepelevigor/assets-cdn/actions/workflows/ci.yml/badge.svg)](https://github.com/chepelevigor/assets-cdn/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE)
[![Node](https://img.shields.io/badge/node-%3E%3D18-brightgreen.svg)](package.json)

`assets-cdn` uploads your static assets (images, video, fonts, …) to any
S3-compatible bucket that backs a CDN — **AWS S3, Cloudflare R2, Tigris, MinIO** —
and gives every file a content hash in its name:

```
img/logo.png  →  img/logo.ab12cd34ef56.png
```

Because the URL changes whenever the bytes change, every asset can be served
with `Cache-Control: public, max-age=31536000, immutable` and cached forever —
no cache-busting query strings, no stale images after a deploy.

It also generates a typed `assets.ts` so your app references assets by their
**logical** name and always gets the current hashed URL:

```ts
import { asset } from "./assets";

asset("img/logo.png"); // → https://cdn.example.com/assets/img/logo.ab12cd34ef56.png
//     ^^^^^^^^^^^^^^  autocompleted & type-checked; a typo won't compile
```

## Why

- **Immutable caching, done right.** Hashed names make `max-age` forever safe.
- **Safe partial uploads.** Uploading one source folder never deletes assets it
  doesn't own; only new/changed files are pushed.
- **Race-safe manifest.** The `file-map.json` is published with compare-and-swap
  (`If-Match`/`If-None-Match`), so two CI runners deploying at once merge instead
  of clobbering each other — a 412 just triggers a re-read and retry.
- **Fast & resilient.** Uploads run with bounded concurrency and retry transient
  errors with exponential backoff; `--verify` confirms every object landed.
- **Deferred deletion.** Retired versions are tagged `pending-delete` with a
  grace window (default 7 days) so a rollback still has its old assets — then
  purged by `cleanup-pending`.
- **Typed access.** `assets.ts` turns asset keys into a compile-time contract.
- **Responsive images.** Optionally transcode images to AVIF/WebP at multiple
  widths on upload and get typed `srcset` helpers — see below.
- **Recoverable.** Lost your local map? `rebuild-from-cdn` reconstructs it from
  what's already published.

## Install

```bash
npm install -g assets-cdn
# or run without installing:
npx assets-cdn --help
```

## Configuration

Copy `.env.example` to `.env` and fill in your bucket credentials:

```env
STATIC_BUCKET_NAME=my-bucket
STATIC_CDN_BASE=https://cdn.example.com
STATIC_AWS_ENDPOINT_URL_S3=https://s3.example.com   # omit for AWS S3
STATIC_AWS_ACCESS_KEY_ID=your-access-key-id
STATIC_AWS_SECRET_ACCESS_KEY=your-secret-access-key
AWS_REGION=auto                                     # "auto" for R2/Tigris
```

Commands that touch the CDN fail fast with a clear message if a required
variable is missing.

## Quick start

```bash
# 1. Preview what would change (no writes)
assets-cdn upload-partial --src ./assets --cdn imgs --plan

# 2. Upload new/changed files, publish the map, emit typed assets.ts
assets-cdn upload-partial \
  --src ./assets \
  --cdn imgs \
  --out ./file-map.json \
  --assets-ts ./src/assets.ts

# 3. Later, purge versions whose grace window has elapsed
assets-cdn cleanup-pending --cdn imgs
```

## Responsive images

With `--images`, source images (`jpg`/`png`/`webp`) are transcoded to modern
formats at multiple widths on upload, content-hashed, and recorded in the
manifest. The generated `assets.ts` then gives you typed `srcset` helpers:

```bash
assets-cdn upload-partial --src ./assets --cdn imgs \
  --images --image-formats avif,webp --image-widths 640,1280,1920 \
  --assets-ts ./src/assets.ts
```

```tsx
import { imageSrcSet, imageSources, IMAGES } from "./assets";

// A single format as srcset:
<img
  srcSet={imageSrcSet("hero.jpg", "avif")}
  width={IMAGES["hero.jpg"].width}     // intrinsic size → no layout shift
  height={IMAGES["hero.jpg"].height}
/>;

// Or a full <picture> with avif → webp fallback:
<picture>
  {imageSources("hero.jpg").map((s) => (
    <source key={s.type} type={s.type} srcSet={s.srcset} />
  ))}
  <img src={/* fallback */ IMAGES["hero.jpg"].fallback} />
</picture>;
```

Image processing uses [`sharp`](https://sharp.pixelplumbing.com), an **optional
dependency** — it's only needed when you pass `--images`. Variants never upscale
past the source width, output is deterministic (metadata stripped, EXIF
orientation baked in), and each variant gets an immutable cache header.

> Scoped for v1: AVIF/WebP at configurable widths, original fallback, intrinsic
> dimensions. Not yet included: `<picture>` art-direction crops, LQIP/blur
> placeholders, and animated GIF → video.

## Vite plugin

Upload assets automatically as part of `vite build` — no separate CLI step:

```ts
// vite.config.ts
import { defineConfig } from "vite";
import assetsCdn from "assets-cdn/vite";

export default defineConfig({
  plugins: [
    assetsCdn({
      src: "src/assets",          // defaults to Vite's build.outDir
      cdn: "imgs",
      assetsTs: "src/assets.ts",  // regenerate the typed manifest on each build
      enabled: !!process.env.CI,  // e.g. only upload in CI
    }),
  ],
});
```

The plugin runs on `closeBundle` (build only), reuses the same safe partial
upload — content hashing, race-safe manifest, deferred deletion — and reads the
bucket from the standard `STATIC_*` environment variables. All CLI options
(`dryRun`, `concurrency`, `verify`, `pendingDays`, …) are accepted.

## Commands

| Command            | Description                                                                  |
| ------------------ | ---------------------------------------------------------------------------- |
| `upload-partial`   | Upload new/changed assets, publish the file-map, mark retired versions       |
| `cleanup-pending`  | Permanently delete objects whose `pending-delete` window has passed          |
| `rebuild-from-cdn` | Rebuild `file-map.json` (and `assets.ts`) from objects already on the CDN    |
| `sync-fix-headers` | Repair `Content-Type` / `Cache-Control` headers on the CDN                   |
| `delete`           | Delete CDN objects that no longer exist locally                              |
| `upload-json`      | Upload a local `file-map.json` to the CDN                                    |
| `download-json`    | Download `file-map.json` from the CDN                                        |
| `generate-assets`  | Generate `assets.ts` from a local `file-map.json` (offline, no CDN needed)   |

## Options

| Option              | Description                                             | Default              |
| ------------------- | ------------------------------------------------------- | -------------------- |
| `--src <path>`      | Source directory (or JSON path for `generate-assets`)   | `assets-to-upload`   |
| `--out <path>`      | Output path for `file-map.json`                         | `file-map.json`      |
| `--cdn <prefix>`    | Key prefix on the CDN                                   | `assets`             |
| `--assets-ts <path>`| Also emit a typed `assets.ts` module at this path       | —                    |
| `--pending-days <n>`| Grace period before `pending-delete` purge              | `7`                  |
| `--concurrency <n>` | Max concurrent S3 requests                              | `16`                 |
| `--verify`          | After upload, confirm objects exist with expected size  | `false`              |
| `--images`          | Generate responsive image variants (needs `sharp`)      | `false`              |
| `--image-widths <l>`| Comma-separated widths for variants                     | `640,1280,1920`      |
| `--image-formats <l>`| Comma-separated formats (`avif`,`webp`,`jpeg`,`png`)   | `avif,webp`          |
| `--plan`            | Show the execution plan (diff only), make no changes    | `false`              |
| `--dry-run`         | Simulate all actions without uploading/deleting         | `false`              |
| `--json`            | Emit a machine-readable JSON result (for agents/CI)     | `false`              |
| `-v, --version`     | Show version                                            | —                    |
| `-h, --help`        | Show help                                               | —                    |

## For AI agents & CI

`assets-cdn` is built to be driven by machines, not just humans.

### Machine-readable output

Add `--json` to any command and it prints a single JSON object to stdout (all
human logs are suppressed) with a stable exit code — `0` on success, `1` on
error (the error is emitted as `{"error": "..."}` on stderr):

```bash
assets-cdn upload-partial --src ./assets --cdn imgs --plan --json
# {"command":"upload-partial","dryRun":false,"uploaded":["imgs/logo.ab12.png"],
#  "pendingDelete":[],"kept":4,"out":"file-map.json","planned":true}

assets-cdn upload-partial --src ./assets --json | jq '.uploaded | length'
```

### MCP server (Claude Code, Claude Desktop, Cursor…)

The package ships an [MCP](https://modelcontextprotocol.io) server so AI agents
can call every command as a tool (`upload_partial`, `cleanup_pending`,
`rebuild_from_cdn`, `sync_fix_headers`, `delete_stale`, `upload_json`,
`download_json`, `generate_assets`). Register it:

```jsonc
// claude_desktop_config.json  (or .mcp.json / .cursor/mcp.json)
{
  "mcpServers": {
    "assets-cdn": {
      "command": "npx",
      "args": ["-y", "assets-cdn-mcp"],
      "env": {
        "STATIC_BUCKET_NAME": "my-bucket",
        "STATIC_CDN_BASE": "https://cdn.example.com",
        "STATIC_AWS_ENDPOINT_URL_S3": "https://s3.example.com",
        "STATIC_AWS_ACCESS_KEY_ID": "…",
        "STATIC_AWS_SECRET_ACCESS_KEY": "…",
        "AWS_REGION": "auto"
      }
    }
  }
}
```

Then just ask: *"upload the assets in ./dist to the `imgs` prefix and show me
what changed"* — the agent calls `upload_partial` and reads the structured
result back.

## How partial upload works

```text
   local files ──generate──▶ patch map ──┐
                                          ├─ diff vs. published file-map
   CDN file-map ────────────────────────┘
                                          │
        ┌─────────────────────────────────┴─────────────────────────────┐
        │  new / changed → UPLOAD      unchanged → KEEP                  │
        │  superseded    → mark pending-delete (delete-after = +N days)  │
        │  CDN-only      → left untouched                                │
        └───────────────────────────────────────────────────────────────┘
                                          │
                       cleanup-pending ───┘  (deletes once delete-after passes)
```

The upload never removes an object just because it is absent locally — only the
*previous version of a file you re-uploaded* is retired, and even then only
after the grace window. That makes deploys safe to roll back.

The manifest itself is published with **compare-and-swap**: `assets-cdn` re-reads
the currently published `file-map.json`, merges this run's entries into it, and
writes conditionally on the object's ETag. If another deploy wrote in between, the
conditional write fails with `412` and the merge is retried — so parallel deploys
never lose each other's assets. (Requires an S3 provider with conditional writes:
AWS S3, Cloudflare R2, Tigris and MinIO all support them.)

## Development

```bash
pnpm install
pnpm test              # fast unit/mock suites (vitest, no Docker)
pnpm test:integration  # end-to-end against a real MinIO container (needs Docker)
pnpm typecheck         # tsc --noEmit
pnpm build             # bundle to dist/ via tsup
pnpm dev <cmd>         # run the CLI from source (tsx)
```

- [`src/core.ts`](src/core.ts) — pure logic (hashing, name ↔ hash conversion,
  diffing, header rules), no I/O.
- [`src/index.ts`](src/index.ts) — S3/IO orchestration; each command returns a
  structured `CommandResult`.
- [`src/cli.ts`](src/cli.ts) — CLI entry.
- [`src/mcp.ts`](src/mcp.ts) — MCP server exposing the commands as agent tools.

Tests cover the pure logic ([`core.test.ts`](test/core.test.ts)), the S3 command
paths against a mocked S3 ([`commands.test.ts`](test/commands.test.ts)), the
`--json` contract ([`json.test.ts`](test/json.test.ts)), the MCP server over an
in-memory transport ([`mcp.test.ts`](test/mcp.test.ts)), and full end-to-end runs
against a real MinIO container
([`test/integration/minio.test.ts`](test/integration/minio.test.ts)) — including
the compare-and-swap manifest under genuine concurrent deploys.

## License

[MIT](./LICENSE) © Igor Chepelev