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

A
license - permissive license
Not graded
quality - not tested
B
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

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
    142
    MIT

View all related MCP servers

Related MCP Connectors

  • AI-manageable audio CDN: upload, transcode, normalize, stream & deliver audio, plus grounded docs.

  • Sync Lightroom, Figma, Dropbox & Canva assets to WordPress and Shopify via natural language.

  • Sync Lightroom, Figma, Dropbox & Canva assets to WordPress and Shopify via natural language.

View all MCP Connectors

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/ChepelevIgor/cdn-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server