@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 (
stringorUint8Array) with a MIME type inmeta.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:
?acceptquery parameter (explicit override)Acceptheader (standard negotiation with q-values)- 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 documentTransformers
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-siteQuick 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.cssEdge / 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 contentPOSTon aCollectionwithmeta.location→ 201 createdDELETE→ 204PartialContentshape → 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"); // nullTrailing 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:
serializationcontrols how resource data is embedded in the rendered HTML (head JSON-LD vs.data-resourceattribute). It is independent of the legacyhydrationoption, 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-pastefindResourceData - Built-in client-side router —
elementMapconfig generates client-side routing bodyHtmlconfig 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
