elysia-rack
v0.2.2
Published
Model-driven CRUD rack for Elysia: REST + QUERY routes, validation, authorization, OpenAPI metadata, and React panel rendering.
Maintainers
Readme
elysia-rack
Model-driven CRUD rack for Elysia: declare a resource once, get REST + QUERY routes, validation, authorization, query engine, OpenAPI metadata, and a React panel — running on Bun or Node.js 20+.
Install
bun add elysia-rack elysia react react-dom
# or
npm i elysia-rack elysia react react-domelysia, react, and react-dom are peer dependencies.
Usage
import { Elysia } from "elysia";
import { rack } from "elysia-rack";
const app = new Elysia().use(
rack("/catalog/products", {
model: { drizzle: { db, table: products } },
validation: {
create: CreateProductSchema,
replace: ReplaceProductSchema,
update: UpdateProductSchema,
params: ProductParamsSchema,
query: ProductQuerySchema,
},
authorization: {
list: "products.view",
create: "products.create",
delete: async ({ request }) => canDelete(request),
},
query: {
searchable: ["name", "sku"],
filterable: ["status", "categoryId"],
sortable: ["name", "price", "createdAt"],
defaultSort: { field: "createdAt", direction: "desc" },
pagination: { default: 20, max: 100 },
},
operations: { create: true, delete: false },
metadata: {
id: "products",
label: "Product",
pluralLabel: "Products",
group: "Catalog",
},
openapi: { tags: ["Products"] },
settings: { primaryKey: "id", returning: true },
}),
);
app.listen(3000);Generated routes (all gated by operations, default on):
| Operation | Method | Path |
|---|---|---|
| display panel | GET | / |
| list data | QUERY | /data |
| detail data | QUERY | /data/:id |
| create | POST | / |
| replace | PUT | /:id |
| update | PATCH | /:id |
| delete | DELETE | /:id |
GET / renders the React panel via page("/panel") (customize with the
page option, disable with page: { enabled: false }). All data reads go
through the QUERY method under /data. The panel UI calls the same
endpoint live via fetch (progressive enhancement — links and forms still
work through plain GET navigation without JavaScript).
Panel actions
The panel ships create / edit / delete UI driven by the same API:
+ Newopens a create dialog (fields derived from row columns, primary key optional). Submit sendsPOST /with an auto-generatedIdempotency-Key. Tables without known columns fall back to a raw JSON body field.- Row
Editloads the row viaQUERY /data/:idinto a dialog and saves withPATCH /:id. Empty inputs are skipped (partial update). - Row
Deleteconfirms natively, thenDELETE /:id. - Checkbox selection enables the bulk bar:
Delete selectedloopsDELETE /:id, and the field applier loopsPATCH /:idwith the chosen field/value.Clearresets the selection.
Action UI follows operations: disable create/update/delete and
the corresponding buttons, dialogs and bulk controls disappear.
Idempotency (POST)
POST / requires an idempotency key by default. Send a client-generated
key per create attempt:
curl -X POST localhost:3000/catalog/products \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000' \
-d '{"name":"Keyboard"}'Replays with the same key return the stored 201 response with an
Idempotent-Replayed: true header instead of re-executing. Missing keys
are rejected with 400. Tune via the idempotency option:
rack("/catalog/products", {
model: { drizzle: { db, table: products } },
idempotency: {
required: false, // allow keyless POST (default true)
header: "X-Idempotency-Key",
ttl: 3600, // replay window in seconds (default 86400)
store: myRedisStore, // shared store for multi-instance deploys
},
});ORM adapters
model selects the adapter. Drizzle needs drizzle-orm installed
(optional peer); Prisma needs no extra dependency — pass any client
delegate (e.g. prisma.product):
// Drizzle (Postgres, SQLite, ...)
import { drizzle } from "drizzle-orm/node-postgres";
model: { drizzle: { db: drizzle(client), table: products } },
// Prisma
model: { prisma: { client: prisma, model: prisma.product } },Conventions:
- String route ids are coerced to numbers for integer columns
(e.g.
serial); anything else passes through. Malformed ids resolve to404, missing rows to404. searchablefields use SQLLIKE(case-sensitivity follows the DB).settings.softDelete: truewrites a timestamp todeletedAt(override withsettings.deletedAtField) instead of deleting, and reads skip soft-deleted rows.settings.returning: falseomitsdatafrom mutation responses.
Dashboard
dashboard() is now a simple Coming Soon placeholder. The real navigation lives in panel — each rack() panel shows a sidebar with the entire tree.
Recommended setup:
import { dashboard, rack } from "elysia-rack";
import { reactPlugin } from "elysia-rack/react";
const app = new Elysia()
.use(reactPlugin()) // pages are optional — defaults to dashboard + panel
.use(dashboard({ title: "My Panel", path: "/" })) // → Coming Soon at /
.use(rack("/catalog/products", { model, metadata: { id: "products", group: "Catalog", order: 1 } }))
.use(rack("/catalog/variants", { model, metadata: { id: "variants", group: "Catalog", order: 2, parent: "products" } }));
// → Panel at /catalog/products now shows sidebar with Catalog > Products > VariantsTree metadata
metadata.parent builds a hierarchy. Children are rendered nested under their parent in panel’s sidebar and kept ordered by order then label.
rack("/catalog/products", { metadata: { id: "products", group: "Catalog", order: 1 } });
rack("/catalog/variants", { metadata: { id: "variants", parent: "products", group: "Catalog", order: 2 } });Helpers: listRacks(), getRack(id), getRackTree(), flatRackTree(), buildRackTree(racks), clearRacks() from elysia-rack.
Donate — disable on website
Donate link (Ko-fi) tampil di Dashboard & Panel footer. Disable via dashboard({ donate: false }) atau global reactPlugin({ donate: false }) (default true).
You can still render the dashboard manually:
import { page, reactPlugin } from "elysia-rack/react";
const app = new Elysia()
.use(reactPlugin())
.use(rack("/catalog/products", { model, metadata: { ... } }))
.get("/", () => page("/dashboard", { name: "My Shop" }));panel now handles the sidebar + breadcrumb. Each panel loads the full tree via getRackTree(), highlights the active resource (props.resource), and links via href: node.path. No iframe needed.
Tutorial — make it yours
This guide walks you through rack(), dashboard() and react() the way you would build a real app. Copy, run, tweak.
1) rack() — describe a resource, get an API + panel
Start with the only required thing: which table/model to use.
import { rack } from "elysia-rack";
rack("/catalog/products", {
model: { drizzle: { db, table: products } } // or { prisma: { client: prisma, model: prisma.product } }
});You already get GET /, QUERY /data, POST /, PUT/PATCH/DELETE /:id and a panel. Now make it yours step by step.
Give it a name and a place in the menu. metadata is what the dashboard reads from memory.
rack("/catalog/products", {
model: { drizzle: { db, table: products } },
metadata: {
id: "products", // unique id, defaults to path
label: "Product", // singular
pluralLabel: "Products", // shown in sidebar
group: "Catalog", // sidebar group
order: 1, // smaller = higher
icon: "📦",
parent: "catalog-root", // nest under another id
hidden: false
}
});Decide what users can do. Turn operations on/off. If you disable create, the “+ New” button disappears automatically.
rack("/catalog/products", {
model,
operations: { list: true, detail: true, create: true, replace: true, update: true, delete: false }
});Shape the list experience. Which fields can be searched, filtered, sorted? What’s the default?
rack("/catalog/products", {
model,
query: {
searchable: ["name", "sku"],
filterable: ["status"],
sortable: ["name", "price", "createdAt"],
defaultSort: { field: "createdAt", direction: "desc" },
pagination: { default: 20, max: 100 }
}
});Add safety. Validation and authorization are per-operation. A string like "products.delete" checks the x-permissions header; a function gives you the full Request.
import { t } from "elysia";
rack("/catalog/products", {
model,
validation: {
create: t.Object({ name: t.String({ minLength: 3 }) }),
update: t.Partial(t.Object({ name: t.String() })),
params: t.Object({ id: t.String() }),
query: t.Object({ search: t.Optional(t.String()) })
},
authorization: {
list: true,
delete: async ({ request }) => request.headers.get("x-api-key") === "secret"
// or: delete: "products.delete"
}
});Tweak the small things when you need them:
rack("/catalog/products", {
model,
page: { enabled: true, path: "/panel" }, // change or disable GET / panel
settings: { primaryKey: "slug", softDelete: true, deletedAtField: "deletedAt", returning: true },
openapi: { tags: ["Catalog"], description: "Products", operations: { list: { summary: "List products" } } },
idempotency: { required: true, header: "Idempotency-Key", ttl: 3600, store: myRedisStore }
});That’s the whole rack() surface. Start with model, then add metadata → query → validation/authorization as your app grows.
2) dashboard() — one line, now Coming Soon
dashboard() mounts GET / that renders a Coming Soon page. The tree you registered with rack() is not shown here — it lives in panel.
import { dashboard, rack } from "elysia-rack";
import { reactPlugin } from "elysia-rack/react";
const app = new Elysia()
.use(reactPlugin()) // 1) UI engine — pages optional
.use(dashboard({ title: "My Shop", path: "/" })) // 2) Coming Soon at /
.use(rack("/catalog/products", { model, metadata: { id: "products", group: "Catalog", order: 1 } }))
.use(rack("/catalog/variants", { model, metadata: { id: "variants", parent: "products", group: "Catalog", order: 2 } }));
// visit /catalog/products → panel shows sidebar with Catalog > Products > VariantsOptions — all optional:
path(default"/") — where the dashboard livestitle(default"Panel") — header in the sidebarpagePath(default"/dashboard") — which React page to render
Need it without the helper? The helper is just sugar for:
import { page } from "elysia-rack/react";
.get("/", ({ query }) => page("/dashboard", { name: "My Shop", resource: query.resource as string }))Helpers to inspect the tree yourself: listRacks(), getRack(id), getRackTree(), flatRackTree(), buildRackTree(racks), clearRacks().
3) react() — pages and look
reactPlugin is the bridge between Elysia and React. pages is now optional and mergeable — defaults already include "/dashboard" and "/panel", and any custom pages you pass are merged in. You can pass a single registry or an array.
import { pages, reactPlugin, page } from "elysia-rack/react";
// Option A: just use defaults
new Elysia().use(reactPlugin());
// Option B: extend defaults with your own page
const myPages = { "/hello": () => import("./Hello") };
new Elysia().use(reactPlugin({ pages: myPages })); // merged with defaults
// Option C: merge multiple registries — array will be merged
const extra = { "/admin": () => import("./Admin") };
new Elysia().use(reactPlugin({ pages: [pages, myPages, extra] }));
// With a style override
new Elysia().use(reactPlugin({
pages: myPages, // or [pages, myPages]
css: "./my-panel.css", // file path
// css: { path: "./my-panel.css" },
// css: { content: ":root{--color-primary:red}" },
// panelCss: "body{...}" // alias
})).get("/hello", () => page("/hello", { name: "Ada" }));What each option does:
pages(optional,PageRegistry | PageRegistry[]) — registry of lazy page components. If omitted, defaults are used. If you pass one or an array, they are merged with defaults so"/dashboard"+"/panel"stay available. Later entries win on key collision.css/panelCss— replacedist/panel.cssserved at/__rack/panel.css. If the string points to an existing file it’s used, otherwise it’s treated as raw CSS. Useful for quick theming without forking.
Under the hood reactPlugin also serves /__rack/panel-theme.js and /__rack/panel-app.js and turns any page() return value into streamed HTML using src/react/app.html ({{title}} + <!--app-->).
HTML template
SSR uses src/react/app.html as the shell. It is read at runtime, cached,
and streamed with the React output.
<!doctype html>
<html><head>
<title>{{title}}</title>
<link rel="stylesheet" href="/__rack/panel.css" />
<!--app-head-->
</head><body>
<!--app-->
</body></html>{{title}}is replaced (escaped) per page.<!--app-->(also{{body}},<!--app-html-->) marks where the React stream is injected.- Fallback shell is used if the file is missing.
dist/app.htmlis a static copy ofsrc/react/app.htmlincluded in the package.
Override the template by editing src/react/app.html (and dist/app.html for the published package).
Panel CSS override
Override panel.css at react() config without forking:
import { reactPlugin } from "elysia-rack/react";
// file path (resolved from cwd/dist), raw string, or object — pages optional
reactPlugin({ css: "./my-panel.css" });
reactPlugin({ css: { path: "./my-panel.css" } });
reactPlugin({ css: { content: ":root{--color-primary:red}" } });
reactPlugin({ panelCss: "body{...}" }); // alias
// with custom pages: reactPlugin({ pages: myPages, css: "./my.css" })
// or array: reactPlugin({ pages: [pages, myPages], css: "./my.css" })If a file path exists it is served; otherwise a raw CSS string is served.
Fallback is dist/panel.css.
Panel assets
Static assets are served under /__rack/*:
dist/panel.css— panel stylesheet (prebuilt)dist/panel-theme.js— dark/light toggledist/panel-app.js— live query + actions (importscomponents/href.ts,components/panelForm.ts)
Pages reference /__rack/panel.css, /__rack/panel-theme.js and
/__rack/panel-app.js via reactPlugin. No build step required — assets are
static and included in the published package.
Panel theme
src/react/page/panel.css is the stylesheet entry:
theme/tokens.css— full semantic tokens (surface, text, status, chart, selection, focus, overlay, navigation, table, form, sidebar), shadcn/Tailwind compatible, mapped via@theme inline.theme/dark.css—.darkoverrides.theme/koran.css— default newspaper theme (warm paper, serif headlines). Remove its import for the neutral look.theme/components.css— newspaper component classes + base styles.
Dark mode flips automatically: stored choice (localStorage), else
prefers-color-scheme. The ◐ Theme button in the masthead toggles it.
Components must only use semantic utilities (bg-background,
text-foreground, bg-table-row-hover, …) so themes swap without
touching React code.
Release (maintainer)
- One-time setup: npmjs.com → package → Settings → Trusted Publisher → GitHub Actions (
notoofly/elysia-rack, workflowpublish.yml). - Bump
versioninpackage.json. - Create a GitHub Release with tag
v<version>(e.g.v0.1.0). - The
publishworkflow runs automatically: install, typecheck, test, build, tag-vs-version check,npm publish --provenance.
Scripts
bun install # install dependencies
bun run dev # run src/playground.ts
bun test # unit tests (bun:test)
bun run typecheck # tsc --noEmit
bun run build # dist/ (js via bun build + .d.ts via tsc)Donate
Support pengembangan elysia-rack — traktir kopi via Ko-fi:
https://ko-fi.com/notoofly_manu
Terima kasih sudah mendukung open-source!
License
MIT © notoofly
