Skip to main content
Glama

assets-cdn

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

CI License: MIT Node

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:

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.

Related MCP server: StaticX MCP Server

Install

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:

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

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

assets-cdn upload-partial --src ./assets --cdn imgs \
  --images --image-formats avif,webp --image-widths 640,1280,1920 \
  --assets-ts ./src/assets.ts
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, 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:

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

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

// 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

   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

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 — pure logic (hashing, name ↔ hash conversion, diffing, header rules), no I/O.

  • src/index.ts — S3/IO orchestration; each command returns a structured CommandResult.

  • src/cli.ts — CLI entry.

  • src/mcp.ts — MCP server exposing the commands as agent tools.

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

License

MIT © Igor Chepelev

Related MCP Connectors

Related MCP Servers

  • A
    license
    A
    quality
    C
    maintenance
    Enables AI agents to deploy static files and get live HTTPS URLs instantly, with support for custom domains and data residency.
    9
    54 npm
    MIT
  • A
    license
    Not graded
    quality
    D
    maintenance
    Enables AI assistants to deploy generated files (HTML, PDF, images, etc.) directly to cloud71 hosting, returning a public URL, and manage cloud71 sites via natural language.
    MIT