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

renku

v0.0.15

Published

Server-first JSX framework for Cloudflare Workers, built with Bun. Plain-object routes, server-rendered function components, "use client" opt-in interactivity, and server functions that cross the network automatically.

Readme

Renku

Server-first JSX framework for Cloudflare Workers. Routes are plain objects, pages are function components rendered on the server, and interactivity opts in with "use client". Server functions cross the network automatically when imported from client modules.

Built for Bun + Cloudflare Workers. wrangler and miniflare ship as transitive dependencies of renku, so you only install one package.

Install

bun add renku
bun add -d @types/bun

Extend Renku's TypeScript config so JSX uses the Renku runtime and class works as an attribute:

{ "extends": "renku/tsconfig" }

Add scripts. The CLI exposes renku build and renku dev; deploys still go through wrangler deploy against the generated dist/:

{
  "scripts": {
    "build": "renku build",
    "dev": "renku dev",
    "deploy": "bun run build && wrangler deploy"
  }
}

renku dev runs an initial build, boots Miniflare in-process, watches src/ and wrangler.jsonc, hot-reloads the Worker on every save, and injects a live-reload script into HTML responses so the browser refreshes automatically. Set PORT to override the default 8787.

Create wrangler.jsonc with your Worker entrypoint. renku build reads this file from process.cwd().

{
  "$schema": "./node_modules/wrangler/config-schema.json",
  "name": "my-app",
  "main": "src/index.ts",
  "compatibility_date": "2026-04-22",
  "workers_dev": true,
  "observability": { "enabled": true },
}

Project Layout

src/
  index.ts          // Worker entrypoint
  routes.ts         // Route tree
  style.css         // Imported by layout; picked up by Tailwind plugin
  pages/
    _layout.tsx
    index.tsx
    about.tsx
    products/
      _layout.tsx
      [id].tsx
  components/
    counter.tsx     // "use client"  (.tsx)
  server/
    get-message.ts  // "use server"  (.ts)

The filenames are a convention — Renku has no file-system router. Everything is wired up in routes.ts. The only rule the scanner cares about is extensions: "use client" is recognized in .tsx files and "use server" in .ts files.

Worker Entrypoint

Delegate requests to fetchHandler() from renku/router:

import { fetchHandler } from "renku/router";

import { routes } from "./routes";

export default {
  async fetch(req: Request, _env: Env): Promise<Response> {
    return fetchHandler(req, routes);
  },
} satisfies ExportedHandler<Env>;

Durable Objects or other Worker exports live next to default as usual. You can run your own logic before falling through to fetchHandler (WebSocket upgrades, custom subdomain routing, etc.).

Routes

defineRoutes() takes a plain object. Special keys are layout and index; every other string key is a path. HTTP-method keys (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS) mark an object as an API handler.

import { version } from "renku";
import { defineRoutes } from "renku/router";

import IndexPage from "./pages";
import Layout from "./pages/_layout";

export const routes = defineRoutes({
  layout: Layout,
  index: IndexPage,
  "/about": () => import("./pages/about.tsx"),
  "/products/:id": () => import("./pages/products/[id].tsx"),
  "/api": {
    "/version": {
      GET: () => new Response(version),
    },
    "/product/:id": {
      GET: (_req, { params }) => new Response(`Product: ${params.id}`),
    },
  },
});

Rules the router follows:

  • Route values can be a component, a lazy () => import("..."), an API handler object, or a nested defineRoutes config. layout and index themselves can also be lazy imports.
  • Path keys can be written with or without a leading slash ("/about" and "about" are equivalent).
  • Dynamic segments :id are passed as component props and are typed through RouteComponentProps<{ id: string }>. API handlers receive them on ctx.params.
  • More specific routes win over dynamic ones: more static segments first, then longer matches, then declaration order.
  • HTTP method handlers: GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS. HEAD falls back to GET with the body stripped. Requests whose method isn't declared get 405 Method Not Allowed with an Allow header listing the methods that are.
  • layout wraps the current subtree; nest routes to compose layouts.

Nested subtrees can live in their own files:

// src/routes.ts
export const routes = defineRoutes({
  layout: RootLayout,
  "/": webRoutes,
  "/app": appRoutes,
});

// src/app/routes.ts
export const appRoutes = defineRoutes({
  layout: () => import("./_layout.tsx"),
  index: () => import("./index.tsx"),
  "/settings": () => import("./settings.tsx"),
});

Layouts And Pages

Pages and layouts are function components. They may be async on the server.

// pages/_layout.tsx
import type { LayoutComponent } from "renku";

import "../style.css";

const Layout: LayoutComponent = ({ children, scripts, stylesheets }) => {
  return (
    <html>
      <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <title>My App</title>
        {stylesheets.map((s) => (
          <link key={s} rel="stylesheet" href={s} />
        ))}
        {scripts.map((s) => (
          <script key={s} type="module" src={s} async></script>
        ))}
      </head>
      <body>
        <main>{children}</main>
      </body>
    </html>
  );
};

export default Layout;

The root layout is where you emit <html>, inject stylesheets, and load the client bootstrap. Nested layouts just wrap children — they still receive the same props but rarely need them.

// pages/index.tsx
export default function IndexPage() {
  return (
    <div>
      <h1 class="text-4xl">Welcome</h1>
      <a href="/about">About</a>
    </div>
  );
}

Pages with dynamic segments receive them as typed props:

import type { RouteComponent } from "renku/router";

const ProductPage: RouteComponent<{ id: string }> = ({ id }) => {
  return <h1>Product {id}</h1>;
};

export default ProductPage;

Server components can be async — their output streams in after the rest of the tree:

import type { Component } from "renku";

export const Async: Component<{ delay: number }> = async ({ delay }) => {
  await new Promise((r) => setTimeout(r, delay));
  return <div>Done after {delay}ms</div>;
};

JSX Dialect

  • Write class (not className), onclick / onsubmit / oninput (lowercase, like the DOM), and boolean attributes as disabled={true} / disabled={false}.
  • class accepts a string or an array of string | null | undefined that gets joined with spaces (nullish values dropped).
  • Functions on host elements only run inside "use client" modules — server-rendered handlers are dropped silently.
  • Tailwind is supported out of the box via bun-plugin-tailwind; just import your CSS from a layout. Every .css file under src/ is picked up automatically.

Client Components

Put "use client" as the very first line of a .tsx module. The component renders on the server as an HTML comment placeholder, then hydrates in the browser with the same props.

"use client";

import type { Component } from "renku";
import { once, signal } from "renku/state";
import { Button } from "renku/ui";

import { getMessage } from "../server/get-message";

export const Counter: Component<{ count: number }> = ({ count }) => {
  const value = signal(count, "count");

  once(() => {
    void getMessage().then((message) => console.log("Server said:", message));
  }, "server-message");

  return <Button onclick={() => value.set(value.get() + 1)}>Count: {value.get()}</Button>;
};

renku/state

All three hooks can only be called during client render. Pass a key to give a slot a stable identity across re-renders — without a key, slots are tracked positionally like React hooks.

| API | What it does | | ---------------------------- | -------------------------------------------------------------------------------------------------------- | | signal(initialValue, key?) | Reactive value. Returns a { get, set }-style signal (from signal-polyfill). | | once(effect, key?) | Runs effect exactly once on mount. If effect returns a function it runs on unmount. | | task(asyncFn, key?) | Runs asyncFn({ signal }) and exposes { loading, error, data }. Changing the key aborts and restarts. |

"use client";

import type { Component } from "renku";
import { signal, task } from "renku/state";

const wait = (ms: number, abort: AbortSignal) =>
  new Promise<void>((resolve, reject) => {
    const id = setTimeout(resolve, ms);
    abort.addEventListener(
      "abort",
      () => {
        clearTimeout(id);
        reject(abort.reason);
      },
      { once: true },
    );
  });

export const TaskDemo: Component = () => {
  const run = signal(0, "run");
  const result = task(async ({ signal }) => {
    await wait(1500, signal);
    return "done";
  }, run.get());

  return (
    <div>
      <button onclick={() => run.set(run.get() + 1)}>Reload</button>
      {result.loading ? <p>Loading</p> : null}
      {result.error ? <p>Error</p> : null}
      {!result.loading && !result.error ? <p>{result.data}</p> : null}
    </div>
  );
};

Rules of thumb:

  • Keep the client tree small. Most of the page should stay on the server.
  • Pass only serializable props through "use client" boundaries (strings, numbers, plain objects, arrays).
  • Use keys when the slot's identity matters across re-renders (forms, lists, anything reset-sensitive).
  • Keep text inputs uncontrolled. Don't write <input value={signal.get()} oninput={…}> — each keystroke updates the signal, which triggers a re-render that rebuilds the component's top-level host subtree, which replaces the input element and drops focus after the first character. Give the field a name and read its value from the form on submit:
"use client";

import type { Component } from "renku";
import { signal } from "renku/state";

export const ChatBox: Component = () => {
  const messages = signal<string[]>([], "messages");

  const handleSubmit = (e: Event) => {
    e.preventDefault();
    const form = e.currentTarget as HTMLFormElement;
    const field = form.elements.namedItem("message") as HTMLInputElement | null;
    const value = field?.value.trim() ?? "";
    if (!value) return;
    messages.set([...messages.get(), value]);
    form.reset();
  };

  return (
    <form onsubmit={handleSubmit}>
      {messages.get().map((m) => (
        <div>{m}</div>
      ))}
      <input type="text" name="message" placeholder="Say something…" />
      <button type="submit">Send</button>
    </form>
  );
};

The same applies to <textarea> and <select>. If you truly need the current value reactively (live validation, for example), keep the signal but render the derived UI in a sibling node, not as value on the input.

Server Functions

Put "use server" as the very first line of a .ts module (not .tsx — the build scanner only recognizes "use server" in .ts files). Exports become callable from the server directly and, when imported from a client module, the build rewrites the import into an HTTP RPC call to /__renku/server-functions (via capnweb).

"use server";

import { env } from "cloudflare:workers";

export const getMessage = async () => {
  const hello = await env.MY_DURABLE_OBJECT.getByName("ID").sayHello();
  return `Hello from server: ${hello}`;
};
"use client";

import { getMessage } from "../server/get-message";
// In the browser, this is a fetch under the hood. On the server it's a direct call.

Arguments and return values must be JSON-serializable.

Authentication

Renku ships a declarative auth gate and a sealed-cookie session layer.

Attach auth: "required" as a reserved key inside any RouteConfig; it cascades to descendants and gates page routes with a 302 → /login?next=<path> redirect and API routes with 401. Children can override with auth: "public".

import { defineRoutes } from "renku/router";

export const routes = defineRoutes({
  "/app": {
    auth: "required",
    layout: () => import("./pages/app/_layout.tsx"),
    index: () => import("./pages/app/index.tsx"),
  },
  "/login": {
    auth: "public",
    index: () => import("./pages/login.tsx"),
  },
});

After a successful sign-in (passkey, OAuth, anything), seal the user into the session cookie:

import { createSession, destroySession, getSession } from "renku/auth/server";

// Login handler:
const cookie = await createSession({ id, email, name });
return new Response(null, {
  status: 302,
  headers: { location: "/app", "set-cookie": cookie },
});

// Logout handler:
return new Response(null, {
  status: 302,
  headers: { location: "/", "set-cookie": destroySession() },
});

// Anywhere inside a request:
const session = getSession(); // AuthSession | null

Type your user once by augmenting the AuthSessionUser interface:

// src/types.d.ts
declare module "renku/auth/server" {
  interface AuthSessionUser {
    id: string;
    email: string;
    name: string;
  }
}

Requirements: set "nodejs_compat" (or "nodejs_als") in your compatibility_flags, and provide a RENKU_SESSION_KEY secret (hex-encoded AES-GCM key; openssl rand -hex 32).

Authorization beyond "is there a session" — roles, scopes, per-object permission — is intentionally left to handlers and the data-access layer.

UI Primitives

renku/ui ships a small, theme-aware Button and control classNames:

import { Button, buttonClassName } from "renku/ui";

<Button variant="default" size="sm" onclick={handleClick}>Save</Button>
<a class={buttonClassName({ variant: "ghost" })} href="/docs">Docs</a>

Import the theme tokens once from your global stylesheet:

@import "renku/ui/theme";

Build And Run

From the app root (where wrangler.jsonc lives):

bun run build   # renku build
bun run dev     # renku dev — Miniflare in-process with watch mode + live-reload
bun run deploy  # wrangler deploy against ./dist

renku build does the following:

  1. Scan — walks src/ and marks every .tsx starting with "use client" as a client module, every .ts starting with "use server" as a server-function module, and every .css file as a Tailwind input.
  2. Client — bundles the Renku bootstrap plus every client module into dist/client/ with hashed filenames ([name]-[hash].[ext]). CSS is built separately through bun-plugin-tailwind. The resulting hashed URLs are exposed to the root layout as scripts and stylesheets.
  3. Server — bundles the Worker entrypoint and every server-function module into dist/server/, preserving the source directory under src/ as dist/server/[dir]/[name].js. Stable paths let renku dev hot-reload without restarting workerd. Injects the server-function and client-component manifests and inlines the asset URLs (process.env.RENKU_LAYOUT_ASSETS).

It then writes dist/wrangler.jsonc with main pointing at the bundled entrypoint, assets.directory pointing at dist/client/, and no_bundle: true so the output is consumed as-is. .wrangler/deploy/config.json makes wrangler deploy pick up the generated config without extra flags.

Durable Streams

renku/streams ships a DurableStream Durable Object base class for streaming / long-lived connections. Export it (or your subclass of it) from your Worker and bind it in wrangler.jsonc like any other DO.

Schemas

renku/data provides a small declarative Schema type (object, array, string, number, boolean, null, literal, union) and toStandardSchema(schema), which adapts it to the Standard Schema spec so it plugs into any library that consumes Standard Schemas.

renku/data also provides a schema-first store. In memory is the default; Durable Objects can use their SQLite storage, and browser stores can use SQLite Wasm with OPFS.

import { collection, createStore, defineSchema, type InferSchema } from "renku/data";

const userSchema = defineSchema({
  type: "object",
  fields: {
    id: { type: "string" },
    name: { type: "string" },
    age: { type: "number", optional: true },
  },
});

type User = InferSchema<typeof userSchema>;

const db = createStore({
  users: collection({ schema: userSchema }),
});

await db.users.create({ id: "u1", name: "Ada" });
const users = await db.users.find({
  where: { age: { gte: 18 } },
  orderBy: { name: "asc" },
});

Durable Object storage uses the same schema:

import { DurableObject } from "cloudflare:workers";
import { createDurableObjectStore } from "renku/data/cloudflare";

export class App extends DurableObject<Env> {
  readonly db;

  constructor(ctx: DurableObjectState, env: Env) {
    super(ctx, env);
    this.db = createDurableObjectStore(schema, ctx.storage);
  }
}

Browser OPFS SQLite stores need cross-origin isolation headers:

import { createBrowserStore, withSqliteWasmHeaders } from "renku/data/browser";

const db = await createBrowserStore(schema, { filename: "app.sqlite3" });

Use withSqliteWasmHeaders(response) or set Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp yourself before loading a browser SQLite store.

Gotchas

  • "use client" / "use server" must be the first statement in the file — not after imports.
  • "use client" is only recognized in .tsx files; "use server" only in .ts files. Put your server functions in plain .ts modules.
  • signal, once, and task throw if called outside a client render.
  • renku build reads wrangler.jsonc from process.cwd(), so always run it from the app root.
  • Event handlers on server components do nothing; move them into a "use client" module.
  • Props crossing "use client" / "use server" boundaries must be JSON-serializable.
  • Use class — not className — everywhere.