npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@hadoku/cdn-client

v2.1.0

Published

Typed client for the hadoku CDN — image and audio upload, URL composition, and self-hosted media catalogues.

Readme

@wolffm/cdn-client

The contact point between an ecosystem app and the hadoku CDN. Covers the two things every social surface needs and would otherwise reimplement slightly differently in each app: Ctrl+V image upload (and audio upload) and reading a self-hosted media catalogue.

Deliberately not a UI package — no markup, no CSS. That is where apps legitimately differ: watchparty's chat picker and a /tube/ comment box want the same transport and nothing like the same layout. React hooks live behind @wolffm/cdn-client/react; the core has no React dependency at all.

pnpm add @wolffm/cdn-client

Core

import { createCdnClient, filesFromClipboard } from '@wolffm/cdn-client';

const cdn = createCdnClient({ cdnBase: 'https://cdn.hadoku.me' });

// paste
const [file] = filesFromClipboard(event.clipboardData);
const { key } = await cdn.upload(file);

// render
img.src = cdn.urlFor(key);

// catalogue
const memes = cdn.catalogue('/dataplatform/api/memes');
const { results, next } = await memes.search({ query: 'cat' });
memes.recordUse(results[0].key);

Store the key, never the URL

upload() returns key, path and url. Persist the key. The url is a convenience for immediate render.

urlFor(key) exists to make that the easy path rather than a rule people have to remember. The CDN hostname is expected to move — cdn.hadoku.me is same-site with the app, and the isolation upgrade is a separate registrable domain — and a consumer holding keys survives that as a config change while one holding absolute URLs needs a migration over its own history.

This is not hypothetical. Every app that hotlinked Tenor GIFs lost its entire chat back-scroll when that API shut down on 2026-06-30, because the messages held remote URLs that stopped resolving.

Say which picker you are: surface

recordUse reports a pick so the catalogue can order by popularity. Catalogues that serve more than one app also want to know WHICH app picked — pass surface once, where the catalogue is built:

const memes = cdn.catalogue('/dataplatform/api/memes', {
  surface: 'dataplatform',
});
memes.recordUse(key); // POST …/<key>/use?surface=dataplatform

Surface is a property of the consuming app, not of an individual pick, so it belongs on the catalogue rather than on the call — set once and it cannot be forgotten per-pick. recordUse(key, { surface }) overrides it for the rare app running two pickers over one catalogue.

Omitting it sends no parameter at all, exactly as before this existed, so taking this version needs no coordinated server release.

Check the value against the catalogue's accepted set when you wire it up. The hadoku meme catalogue validates against a closed set and silently coerces anything it doesn't recognise to its default rather than erroring — deliberately, since rejecting a pick could otherwise fail a send. Combined with recordUse never rejecting, a typo produces no error, no warning and no failed request; it just files your picks under someone else's label until somebody reads the table. That is exactly how the missing parameter went unnoticed in the first place.

Errors

CdnError carries a reason to branch on rather than a status code:

| reason | meaning | suggested UI | | ------------------ | ----------------------- | --------------------------------------- | | unauthenticated | not signed in | "Sign in to post images" | | forbidden | signed in, tier too low | "Your account can't upload" | | too-large | over its kind's ceiling | "Image too large (max 10 MB)" / 50 MB | | unsupported-type | not an accepted format | "That file type isn't supported" | | rate-limited | too many requests | back off for retryAfterSeconds | | invalid | empty or malformed | generic | | unavailable | CDN or catalogue down | hide the feature, keep the rest working | | network | offline, DNS, transport | "Check your connection" |

unauthenticated and forbidden are kept distinct on purpose: one is fixable by the user and the other is not, so collapsing them produces a sign-in prompt that changes nothing.

rate-limited is kept out of unavailable for the same kind of reason: both are transient, but a 429 is the caller's own doing and retrying the same request later works, so a UI should back off rather than hide the feature. CdnError carries retryAfterSeconds (from the server's Retry-After, in either the delta-seconds or HTTP-date form) — null when the server sent no header, never a guessed delay.

upload() pre-validates size and type and throws before sending, so an oversized file is refused without spending the upload. The size check uses the ceiling for the file's kind — MAX_BYTES.image or MAX_BYTES.audio; a file with no type is held to the larger one and the server applies the right one. Those checks are a courtesy — the server sniffs the bytes and caps the stream regardless of what any client believes.

React

import {
  useCdnClient,
  useCdnUpload,
  useCatalogueSearch,
} from '@wolffm/cdn-client/react';

// Inside a component, get the client from `useCdnClient` — not from a bare
// `createCdnClient(...)` in the render body. See below.
const cdn = useCdnClient({ cdnBase: 'https://cdn.hadoku.me' });

const { uploads, upload } = useCdnUpload(cdn);
const { items, loading, hasMore, loadMore } = useCatalogueSearch(
  cdn,
  '/dataplatform/api/memes',
  query
);

Getting the client: useCdnClient

createCdnClient returns a new object every call, so calling it in a render body produces a new client on every render. useCdnClient memoizes on the option values, so an inline useCdnClient({ cdnBase }) is stable.

A module-scope createCdnClient(...) is equally fine — the only thing to avoid is building one inside a component without memoizing.

The hooks defend against an unstable client anyway: they key off the client's configuration rather than its object identity and read it through a ref, so a rebuilt-but-identical client re-runs nothing, while genuinely repointing at a different cdnBase still refetches. That defence exists because before it, the natural mistake was not a slow render but an unbounded request storm — measured at roughly two catalogue requests a second, for as long as the component stayed mounted.

useCdnUpload gives optimistic blob: previews (revoked on unmount), measures dimensions before upload so a log can reserve layout space, and uploads a multi-file paste concurrently rather than in sequence.

useCatalogueSearch debounces typing, pages by cursor, aborts superseded requests, and runs immediately on mount with an empty query — a catalogue returns its default set for one, so a picker opens populated instead of blank.

Both render nothing. Take the hook and render your own grid.

What the catalogue returns

interface CatalogueItem {
  key: string;
  title: string;
  tags: string[];
  width: number | null;
  height: number | null;
  contentType: string;
}
  • title is the accessible label — put it in alt. It is frequently the only text describing the image.
  • width/height let a grid reserve space so it doesn't reflow on load.
  • One item can carry several tags; the CDN is content-addressed, so an image filed under multiple categories collapses to one key with a merged tag list.
  • Animated and still items are not distinguished by any field. A transcoded clip reports image/webp exactly like a still one. Both render in an <img> — treat anything image/* as renderable and do not branch on a subtype.

Accepted formats

| kind | formats | ceiling | constant | | ----- | ----------------------------------------- | ------- | ---------------------- | | image | PNG, JPEG, GIF, WEBP, AVIF | 10 MB | ACCEPTED_IMAGE_TYPES | | audio | MP3, FLAC, OGG (Opus or Vorbis), M4A, WAV | 50 MB | ACCEPTED_AUDIO_TYPES |

SVG is refused and always will be: the CDN is same-site with the app, and an SVG is a scriptable document rather than an image. Audio is admitted on the opposite grounds — a browser plays it, it never runs it as a page. The server decides by sniffing the bytes, so renaming a file changes nothing.

ACCEPTED_AUDIO_TYPES lists the canonical types the server stores. Browsers spell several of them differently in File.type (audio/x-m4a, audio/x-wav, audio/mp3, …); AUDIO_TYPE_ALIASES maps those, and kindOfType(type) resolves any of them to 'image' | 'audio' | null. Use it rather than an includes() on the lists, or Chrome's m4a will be refused client-side.

Sniffing bytes: @wolffm/cdn-client/media-type

The server's ingest rules themselves — the allowlist, the byte signatures and the per-kind ceilings — are exported for a service that stores or relays bytes for the CDN and has to refuse what the CDN would refuse. It is the very file workers/cdn-api enforces, has no dependencies, and runs in a browser, Node or a Worker.

import {
  detectFormat,
  MAX_BYTES,
  SNIFF_WINDOW,
} from '@wolffm/cdn-client/media-type';

const format = detectFormat(bytes.subarray(0, SNIFF_WINDOW)); // MediaFormat | null
if (!format) throw new Error('unsupported'); // null is a refusal — never fall back to the client's type
if (bytes.length > MAX_BYTES[format.kind]) throw new Error('too large');

Also exported: SIGNATURES, ACCEPTED_TYPES (a display string), acceptedTypesOf(kind), and the types MediaFormat, MediaKind and AcceptedContentType. The constants in the root entry are derived from these.

Paste and drop are image-only unless asked

filesFromClipboard(data) and filesFromDrop(dt) return images by default, exactly as in 1.x — a chat that renders uploads in an <img> must not start receiving mp3s because the CDN would store one. A surface that takes audio says so: filesFromDrop(dt, ['image', 'audio']). isAccepted(file, kinds) is the same check for a single file.

Upgrading from 1.x

MAX_UPLOAD_BYTES and ACCEPTED_TYPES are gone rather than redefined. Widening them in place would have silently admitted audio into every image-only pre-check written against them; removing them makes the choice a compile error instead.

| 1.x | 2.x | | ------------------------------------ | ----------------------------------- | | MAX_UPLOAD_BYTES | MAX_BYTES.image | | ACCEPTED_TYPES.includes(file.type) | kindOfType(file.type) === 'image' | | ACCEPTED_TYPES (as a list) | ACCEPTED_IMAGE_TYPES | | AcceptedType | AcceptedImageType |

MAX_BYTES, the type lists and kindOfType are checked against workers/cdn-api's own values by test/server-parity.test.ts. That test is why this package lives in the same repo as the worker.