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

@takanashi/rikka-site

v0.0.1

Published

Resource-oriented server framework with content negotiation

Readme

@takanashi/rikka-site

Resource-oriented server framework with content negotiation and automatic transformation.

Core Concepts

The Five Levels

| Level | Concept | Materialized by | Example | | ----- | ---------------- | ------------------------------------ | ----------------------------------- | | 1 | Kind | Framework | Collection | | 2 | ResourceFactory | Kind(impl) | Collection({ list, create, ... }) | | 3 | Mount | ResourceFactory(config) → constructor | Users({ table: "users" }) | | 4 | Resource | new Mount(params) | URL /users/42 resolved | | 5 | Operation | resource[operation](ctx) | res.list(ctx) |

Each level only depends on the output of the previous level. Resources are pure domain concepts — they know nothing about HTTP.

Kinds

| Kind | GET | POST | PUT | PATCH | DELETE | Description | | ---------- | ------ | ------ | ------- | ----- | ------ | ------------------------ | | Collection | list | create | — | — | — | Resource collection | | Item | content | — | replace | patch | delete | Collection member | | Singleton | content | — | replace | patch | — | Globally unique resource | | ReadOnly | content | — | — | — | — | Read-only view | | Action | — | invoke | — | — | — | Stateless operation | | Proxy | get | post | put | patch | delete | Proxy resource |

Representation

All data in rikka-site flows as a Repr{ content, meta }:

  • Value content — structured data with a Schema (the "internal" form)
  • Raw content — pre-serialized bytes (string or Uint8Array) with a MIME type in meta.type (the "wire" form)

Handlers return plain values (auto-wrapped as { content: value, meta: {} }) or explicit Repr objects.

Content Negotiation

The same URL serves both API data and pages. The representation is determined by:

  1. ?accept query parameter (explicit override)
  2. Accept header (standard negotiation with q-values)
  3. Default: text/html
GET /users                  → HTML page
GET /users?accept=json      → JSON array
GET /users?accept=jsonld    → JSON-LD document
Accept: application/json    → JSON array
Accept: application/ld+json → JSON-LD document

Transformers

Transformers convert Representations: (Schema | MIME) → (Schema | MIME)

  • Value → Value — transforms structured data (e.g. filtering, enrichment)
  • Value → Raw — serializes to a MIME type (e.g. JSON, CSV, HTML)
  • Raw → Raw — converts between content types (e.g. image → HTML wrapper)

Pipeline: Repr(value, schema) → [Value→Value] → Repr(value', schema') → [Value→Raw] → Repr(raw, mime) → [Raw→Raw] → Repr(raw', mime')

Installation

npm install @takanashi/rikka-site

Quick Start

import {
  Collection,
  Item,
  Singleton,
  Site,
  handleWebRequest,
} from "@takanashi/rikka-site";

const Articles = Collection(() => ({
  schema: { type: "array", items: { type: "object" } },
  list: () => [{ id: 1, title: "Hello Rikka" }],
  create: (ctx) => {
    const article = { id: 2, ...(ctx.body as Record<string, unknown>) };
    return { content: article, meta: { location: `./${article.id}` } };
  },
  children: {
    ":articleId": (id: string) =>
      Item(() => ({
        content: () => ({ id, title: `Article ${id}` }),
        delete: () => ({ content: null, meta: {} }),
      }))(),
  },
}));

const Settings = Singleton(() => ({
  content: () => ({ theme: "dark" }),
  patch: (ctx) => ({
    content: { theme: (ctx.body as Record<string, unknown>).theme as string },
    meta: {},
  }),
}));

const app = new Site({
  articles: Articles(),
  settings: Settings(),
});

export default { fetch: (req) => handleWebRequest(app, req) };

API Reference

Kind Factories

Collection(impl)

Creates a Collection ResourceType. The impl function receives config and returns a descriptor.

const Users = Collection<{ table: string }>(({ table }) => ({
  schema: { type: "array", items: { type: "object" } },
  list: (ctx) => [...items],
  create: (ctx) => {
    items.push(ctx.body as Record<string, unknown>);
    return ctx.body as Record<string, unknown>;
  },
  // Optional
  children: { ":userId": (id: string) => Item(() => ({ ... }))() },
  element: UserListElement,       // Custom element for HTML rendering
  context: "https://schema.org",  // JSON-LD @context
  jsonldType: "UserCollection",   // JSON-LD @type override
}));

Item(impl)

const Article = Item(() => ({
  schema: { type: "object", properties: { id: { type: "number" }, title: { type: "string" } } },
  content: (ctx) => db.find("articles", ctx.params.articleId),
  replace: (ctx) => db.update("articles", ctx.params.articleId, ctx.body as Record<string, unknown>),
  patch: (ctx) => db.patch("articles", ctx.params.articleId, ctx.body as Record<string, unknown>),
  delete: (ctx) => db.remove("articles", ctx.params.articleId),
}));

Singleton(impl)

const Settings = Singleton(() => ({
  content: (ctx) => ({ theme: "dark" }),
  replace: (ctx) => Object.assign(settings, ctx.body as Record<string, unknown>),
  patch: (ctx) => Object.assign(settings, ctx.body as Record<string, unknown>),
}));

ReadOnly(impl)

const Dashboard = ReadOnly(() => ({
  content: (ctx) => ({ userCount: 42, articleCount: 7 }),
}));

Action(impl)

const Search = Action(() => ({
  invoke: (ctx) => searchIndex.query((ctx.body as Record<string, unknown>).query as string),
}));

Proxy(impl)

const ExternalAPI = Proxy(() => ({
  target: (path) => new URL(path, "https://api.example.com"),
}));

Static(config)

Serve static files from a local directory or a custom resolver. Mounted at a Site key, it catches all remaining path segments as a relative file path.

const Assets = Static({ root: "./public" });

const app = new Site({ assets: Assets });
// /assets/style.css → ./public/style.css

Edge / non-Node static files

On Cloudflare Workers, Deno Deploy, or Vercel Edge there is no local filesystem. Use the resolver option to provide files from a bundled manifest, KV store, or any other storage:

const assets = new Map<string, { content: Uint8Array; type: string }>([
  ["style.css", { content: new TextEncoder().encode("body{}"), type: "text/css" }],
  ["index.html", { content: new TextEncoder().encode("<h1>Hi</h1>"), type: "text/html" }],
]);

const Assets = Static({
  resolver: async (path) => assets.get(path) ?? null,
});

const app = new Site({ assets: Assets });

The resolver receives a normalized relative path, must return null for missing files, and should return a Uint8Array or string plus an optional MIME type. Path traversal (..) is rejected with 403 before the resolver is called.

RequestContext

All handlers receive a RequestContext object:

interface RequestContext {
  method: string; // HTTP method
  path: string; // URL path
  resourcePath?: string; // Resolved resource mount path (e.g. "/assets" for a Static resource)
  params: Record<string, string>; // Path parameters
  query: Record<string, string>; // Query parameters
  headers: Record<string, string>; // Request headers (lowercase keys)
  body?: unknown; // Parsed request body
  identity?: Identity; // Auth identity (if authenticated)
  range?: RangeSpec; // Parsed Range header, if present
}

Repr

Handlers return a Repr object ({ content, meta }) or a plain value, which the framework wraps automatically. Transport semantics are inferred from content and meta:

  • { content: null, meta: { location: "./new" } } → 302 redirect
  • { content: null, meta: {} } → 204 no content
  • POST on a Collection with meta.location → 201 created
  • DELETE → 204
  • PartialContent shape → 206 partial content
  • Plain value → 200

Schema

Schemas describe the shape of data returned by handlers. Used by transformers and SDK generation.

const Users = Collection(() => ({
  schema: {
    type: "array",
    items: {
      type: "object",
      properties: {
        id: { type: "number" },
        name: { type: "string" },
        email: { type: "string" },
      },
    },
  },
  list: (ctx) => [...users],
  create: (ctx) => { ... },
}));

Schema types: any, null, boolean, number, string, array, object.

Site Definition

Site(tree, options?)

Declares the resource tree. Object keys become URL path segments, values become resources or sub-trees.

const app = new Site({
  users: Users({ table: "users" }),
  posts: Posts({ table: "posts" }),
  settings: Settings({}),
  admin: {
    // Route group — pure nesting, no resource
    users: Users({ table: "admin_users" }),
  },
  actions: {
    search: Search({}),
  },
});

// Resolves URLs
app.resolve("/users"); // Collection
app.resolve("/users/42"); // Item with params { userId: "42" }
app.resolve("/admin/users"); // Collection
app.resolve("/nonexistent"); // null

Trailing Slash

Use the "/" key in children to define a different resource for trailing-slash URLs:

const Files = Item(() => ({
  content: (ctx) => getFileMetadata(ctx.params.path),
  children: {
    "/": () =>
      Collection(() => ({
        list: (ctx) => listDirectoryContents(ctx.params.path),
        create: (ctx) => createDirectoryEntry(ctx.params.path, ctx.body as Record<string, unknown>),
      }))(),
  },
}));

// /files/docs     → Item (file metadata)
// /files/docs/    → Collection (directory listing)

Auth

Configure authentication with the auth option. Auth resources are ordinary Action resources:

import { HttpError } from "@takanashi/rikka-site";

const JwtVerifier = Action(() => ({
  invoke: (ctx) => {
    const token = ctx.headers["authorization"]?.replace("Bearer ", "");
    if (!token) throw new HttpError(401, "missing token");
    const payload = verifyJwt(token);
    return {
      subject: payload.sub,
      scopes: Array.isArray(payload.scopes)
        ? payload.scopes.join(" ")
        : (payload.scopes as string),
      expiresAt: payload.exp,
    };
  },
}));

const app = new Site(
  {
    "jwt-auth": JwtVerifier({}),
    users: Users({ table: "users" }),
    public: PublicData({}),
  },
  {
    auth: {
      verifier: "jwt-auth",
      rules: [
        { match: "/users/**" }, // requires auth (uses verifier)
        { match: "/public/**", auth: null }, // explicitly no auth
      ],
    },
  },
);

Glob patterns support * (single segment) and ** (any depth).

HTTP Adapters

Site.prototype.handleRequest(request) — Framework-agnostic

const response = await app.handleRequest({
  method: "GET",
  path: "/users/42",
  accept: "application/json",
  headers: { authorization: "Bearer token" },
});
// response: { status: 200, headers: { "Content-Type": "application/json" }, body: "..." }

handleWebRequest(site, request) — Web Standard API (Universal)

Works on any runtime with Request/Response APIs.

import { handleWebRequest } from "@takanashi/rikka-site";

const response = await handleWebRequest(app, request);

createFetchHandler(site) — Convenience wrapper

import { createFetchHandler } from "@takanashi/rikka-site";

export default { fetch: createFetchHandler(app) };

Platform-Specific Adapters

Cloudflare Workers

// src/index.ts
import { Site, createCloudflareWorkerHandler } from "@takanashi/rikka-site";

const app = new Site({
  /* ... */
});

// Standard Workers signature: fetch(request, env, ctx)
export default createCloudflareWorkerHandler(app);

Requires wrangler.toml:

name = "my-site"
main = "src/index.ts"
compatibility_date = "2025-01-01"

With typed bindings:

interface Env {
  MY_KV: KVNamespace;
  DB: D1Database;
  [key: string]: unknown;
}
export default createCloudflareWorkerHandler<Env>(app);

Cloudflare Pages — Advanced Mode

Place a _worker.js in your build output directory. The handler manages both API routes and static assets.

Note: rikka-site requires Advanced Mode. The Functions mode (functions/ directory) is incompatible because it maps one file per endpoint, while rikka-site uses a single entry point with a declarative resource tree.

// _worker.js
import { Site, createCloudflarePagesHandler } from "@takanashi/rikka-site";

const app = new Site({
  /* ... */
});

// All paths → rikka-site, 404s fall back to env.ASSETS
export default createCloudflarePagesHandler(app);

With API prefix (recommended for mixed static + API sites):

// Only /api/* goes to rikka-site, everything else is static
export default createCloudflarePagesHandler(app, { apiPrefix: "/api" });

Vercel Edge Functions

// api/hello.ts
import { Site, handleWebRequest } from "@takanashi/rikka-site";

const app = new Site({
  /* ... */
});

export const runtime = "edge";

// Option 1: Method-specific exports
export function GET(request: Request) {
  return handleWebRequest(app, request);
}

// Option 2: Fetch handler (all methods)
export default { fetch: (req) => handleWebRequest(app, req) };

Deno Deploy

// main.ts
import { Site, createDenoDeployHandler } from "@takanashi/rikka-site";

const app = new Site({
  /* ... */
});

Deno.serve(createDenoDeployHandler(app));

Node.js

import { serve } from "@takanashi/rikka-site/node";

const app = new Site({
  /* ... */
});

const server = serve(app, { port: 3000 });
console.log(`Listening on ${server.host}:${server.port}`);

For an existing http.Server, use createNodeHandler:

import { createServer } from "node:http";
import { createNodeHandler } from "@takanashi/rikka-site/node";

const app = new Site({
  /* ... */
});

const server = createServer(createNodeHandler(app));
server.listen(3000);

Transformers

Transformers are registered on a TransformerRegistry. A Site creates one automatically at app.registry.

import type { Transformer, Repr } from "@takanashi/rikka-site";

const csvTransformer: Transformer = {
  input: { type: "array", items: { type: "object" } },
  output: "text/csv",
  transform(repr: Repr): Repr {
    const data = repr.content as Record<string, unknown>[];
    if (!Array.isArray(data) || data.length === 0) {
      return { content: "", meta: { type: "text/csv" } };
    }
    const header = Object.keys(data[0]!).join(",");
    const rows = data.map((row) => Object.values(row).join(","));
    return { content: [header, ...rows].join("\n"), meta: { type: "text/csv" } };
  },
};

app.registry.register(csvTransformer);

Content Negotiation

negotiate(registry, acceptHeader?, acceptQuery?)

negotiate uses a site's TransformerRegistry to pick a content type that can actually be produced.

import { negotiate } from "@takanashi/rikka-site";

negotiate(app.registry); // { contentType: "text/html" }
negotiate(app.registry, "application/json"); // { contentType: "application/json" }
negotiate(app.registry, "text/html;q=0.9, application/json"); // { contentType: "application/json" }
negotiate(app.registry, undefined, "json"); // { contentType: "application/json" }

Platform Support

rikka-site has zero Node.js dependencies in its core. It works on:

| Platform | Adapter | Entry format | Notes | | ------------------------ | ------------------------------- | ------------------------------------------ | ------------------------------------------- | | Cloudflare Workers | createCloudflareWorkerHandler | export default { fetch(req, env, ctx) } | wrangler.toml required | | Cloudflare Pages | createCloudflarePagesHandler | _worker.js in output dir (Advanced Mode) | Falls back to env.ASSETS for static files | | Vercel Edge | handleWebRequest | export function GET(req) | Add export const runtime = "edge" | | Deno Deploy | createDenoDeployHandler | Deno.serve(handler) | No build step | | Node.js | serve / createNodeHandler | serve(app) or createServer(createNodeHandler(app)) | Import from @takanashi/rikka-site/node | | Any Web Standard runtime | handleWebRequest | Request → Response | Universal adapter |

JSON-LD

When Accept: application/ld+json is requested, resources are serialized as JSON-LD:

  • Collections: { "@context", "@id", "@type", "@graph": [...] }
  • Items/Singletons: { "@context", "@id", "@type", ...data }
  • HTML pages: Include a <script type="application/ld+json"> block

Customize the context and type via context and jsonldType on the descriptor:

const Users = Collection(() => ({
  list: (ctx) => [],
  create: (ctx) => ctx.body as Record<string, unknown>,
  context: "https://schema.org",
  jsonldType: "PersonCollection",
}));

Examples

See examples/blog-site/ for a complete example with:

  • All 6 resource kinds
  • Nested resources (articles → comments)
  • Content negotiation (HTML, JSON, JSON-LD, CSV, plain text)
  • Schema declarations
  • Node.js HTTP server adapter
  • Cloudflare Workers adapter
  • Client-side hydration with Custom Elements (see skills/rikka-site)

Client-Side Hydration

When using rikka-site for full-stack apps (not just APIs), the HTML transformer injects resource data into the DOM so client-side components can hydrate from it.

SSR Output Structure

For each request, the HTML transformer produces:

<blog-layout data-path="/articles" data-kind="Collection">
  <rikka-resource path="/articles" kind="Collection"
                   data-resource='[{"id":1,"title":"..."}]'>
  </rikka-resource>
</blog-layout>

Data Injection Points

| Location | Format | When | |----------|--------|------| | data-resource attribute on <rikka-resource> | Raw JSON (array or object) | serialization: "data-attr" or "both" (default) | | <script type="application/ld+json"> in <head> | JSON-LD with @context, @graph | serialization: "jsonld" or "both" | | DSDOM <template shadowrootmode> | JSON in template content | hydration: "dsdom" | | <script type="application/ld+json"> inside the element | JSON-LD with @context, @graph | hydration: "jsonld" (legacy) |

Note: serialization controls how resource data is embedded in the rendered HTML (head JSON-LD vs. data-resource attribute). It is independent of the legacy hydration option, which remains supported for backward compatibility.

Reading Data on the Client

Extract data from the DOM in your custom element's render():

function findResourceData<T>(el?: Element): T | null {
  // 1. data-resource attribute (primary)
  if (el) {
    const attr = el.getAttribute("data-resource");
    if (attr) try { return JSON.parse(attr); } catch {}
  }
  // 2. JSON-LD script fallback
  const script = document.querySelector('script[type="application/ld+json"]');
  if (script) try { return JSON.parse(script.textContent ?? ""); } catch {}
  return null;
}

// In your component:
render(this) {
  const data = signal(findResourceData<Article[]>(this) ?? []);
  // ... use data reactively with computed()
}

Layout Attributes

The layoutElement wrapper receives these SSR-injected attributes:

| Attribute | Example | Usage | |-----------|---------|-------| | data-path | "/articles" | Current URL path — use for nav active state | | data-kind | "Collection" | Resource Kind name — use for Kind-aware layout |

Read them with this.getAttribute("data-path") (not reactive attributes).

Roadmap: Planned Improvements

Based on real-world usage, these improvements are planned:

  • Exported hydrateData() utility — no need to copy-paste findResourceData
  • Built-in client-side routerelementMap config generates client-side routing
  • bodyHtml config option — inject global UI components without string hacks
  • Single serialization format option — avoid double data payload
  • Layout context passing — dynamic data from server to layout shell