A stupid attempt from a stupid man who lack of foresight trying to make a backend framework.
F*CK NPM PUBLISH i have to change this package name from guri to giri to @boon4681/giri because of the name collision and there is no contact about request a package name that hit npm stupid filter.
Because I can, and I am too lazy to write an OpenAPI spec. Write handlers, return values. Giri infers the OpenAPI spec from the handlers, and generates types for params and openapi.json from them. Runs on Hono.
Status: early and experimental. Hono is the only adapter today; the API will change.
yarn add @boon4681/giri hono @hono/node-server zodhono, @hono/node-server, zod, valibot, and typescript are optional peers - install
only what you use.
npx giri init # scaffold giri.config.ts + src/routes + tsconfig + .gitignore
npx giri sync # generate .giri/ (manifest, param types, openapi.json)
npx giri serve # sync, then run the dev server (watches src/ and re-syncs)Then hit it:
curl http://localhost:3000/giri.config.ts is declarative - it is loaded at build time, so keep it cheap and free of
side effects (no DB drivers here; see Lifecycle).
import { defineConfig } from "@boon4681/giri";
import { hono } from "@boon4681/giri/adapters/hono";
export default defineConfig({
adapter: hono(), // required: the backend bridge
server: { port: 3000, hostname: "127.0.0.1" },
outDir: ".giri", // where generated output lives
alias: { "$db": "./src/db.ts" }, // import aliases, also written into tsconfig
});Every URL segment is a folder; every HTTP verb is its own file inside it.
src/routes/
+get.ts -> GET /
+shared.ts -> folder config for everything below
users/
+get.ts -> GET /users
+post.ts -> POST /users
[id]/
+get.ts -> GET /users/:id
posts/
[postId]/
+get.ts -> GET /users/:id/posts/:postId
db.ts -> no '+' prefix = plain helper, ignored by the router
[id]folder becomes the param:id; params nest down the path.- Files without a
+prefix are not routes - colocate helpers freely.
A verb file has one shape: the handle named export is the handler. Everything else is an
optional named export, so the trivial case is one line and complexity is additive.
// src/routes/users/[id]/+get.ts
import type { Handle } from "./$types"; // generated per folder; binds c.params to this path
import { findUser } from "../../../db";
export const handle: Handle = (c) => {
const user = findUser(c.params.id); // c.params.id is typed as string
if (!user) return c.json({ message: "user not found" }, 404);
return c.json(user);
};
// inferred responses: 200 -> User, 404 -> { message: string }Giri owns c, so the return type is the schema on every backend:
c.json(data, status?)/c.text(text, status?)- return value carries the status in its type.c.params- typed from the folder path.c.req.valid("body" | "query")- parsed, typed input (see below).c.req.header(name),c.req.url, etc.c.get(key)/c.set(key, value)- per-request vars from middleware.c.app- app-wide services fromsrc/main.tsinit()(see Lifecycle).
Outputs are inferred; inputs are declared with a wrapped schema so giri gets both runtime
validation and a JSON Schema for the doc. Wrappers live in @boon4681/giri/validators/zod and
@boon4681/giri/validators/valibot.
// src/routes/users/+post.ts
import { z } from "zod";
import { zod } from "@boon4681/giri/validators/zod";
import type { POST } from "./$types";
export const body = zod.body({
json: z.object({ name: z.string().min(1) }),
});
export const query = zod.query(z.object({ page: z.coerce.number().default(1) }));
export const handle: POST = (c) => {
const { name } = c.req.valid("body"); // typed + validated
return c.json({ name }, 201);
};zod.body can map multiple content types (json, form) dispatched on Content-Type at
runtime. An unwrapped schema is rejected at build time.
Middleware use giri's (c, next) shape and live in two places:
- Broad:
export const middlewarein a folder's+shared.ts- applies to the whole subtree. - Precise:
export const middlewarein a verb file - applies to that one verb.
Use stack(...) instead of a plain array so injected vars keep their types and propagate to
downstream handlers. Run order: inherited +shared.ts (root to leaf), then the verb's
middleware, then the handler.
// src/routes/+shared.ts
import { stack } from "@boon4681/giri";
import type { Middleware } from "./$types";
const requestId: Middleware<{ requestId: string }> = async (c, next) => {
c.set("requestId", c.req.header("x-request-id") ?? "example-request");
await next();
};
export const middleware = stack(requestId);
// every handler below now sees c.get("requestId"): stringTag a middleware with defineMiddleware to feed OpenAPI security automatically - a route that
uses it shows the scheme, a public route does not.
// src/auth.ts
import { defineMiddleware } from "@boon4681/giri";
export const auth = defineMiddleware<{ userId: string }>(
{
openapi: {
security: [{ bearerAuth: [] }],
securitySchemes: { bearerAuth: { type: "http", scheme: "bearer" } },
},
},
async (c, next) => {
// verify a token, then c.set("userId", ...)
await next();
},
);defineMiddleware can also own a body or query validator. Validation runs before the middleware,
c.req.valid(...) is typed inside it, and every downstream handler receives the same typed input.
The validator is also included in generated OpenAPI:
const pagination = defineMiddleware(
{
query: zod.query(z.object({
page: z.coerce.number().int().positive(),
})),
},
async (c, next) => {
const { page } = c.req.valid("query");
c.set("page", page);
await next();
},
);When a middleware both injects context vars and owns a validator, declare the vars with the
curried form - the empty () takes the Vars type argument first so the validator output is still
inferred (a Vars argument on defineMiddleware(options, ...) directly would suppress inference and
erase c.req.valid(...) to any, so that form is a type error):
const pagination = defineMiddleware<{ page: number }>()(
{
query: zod.query(z.object({ page: z.coerce.number().int().positive() })),
},
async (c, next) => {
c.set("page", c.req.valid("query").page); // valid("query") is typed; c.get("page") is number
await next();
},
);When several applied layers declare the same target (body or query) - e.g. a +shared.ts
pagination middleware plus a route's own query - their validators are merged: each runs and
the validated outputs are combined into the single c.req.valid(...) (and one set of OpenAPI
parameters). Owners run middleware-first, then the route, so a route's fields win on key collisions.
This mirrors the type layer, which intersects them. Validators are run independently, so a schema
that rejects unknown keys (e.g. zod .strict()) should not be combined with another owner.
Hide a route or subtree from openapi.json with export const openapi = false (in a verb file
or a +shared.ts). Hidden routes still serve normally.
src/main.ts is the optional home for imperative startup - opening pools, validating env,
graceful shutdown. Giri owns the serve and calls these hooks; the adapter still binds the port.
// src/main.ts
import type { Services } from "@boon4681/giri";
export const init = () => {
// leave init unannotated - its return type is the source of truth for c.app
return { db: connectDb(process.env.DATABASE_URL) };
};
export const teardown = (services: Services) => {
return services.db.close(); // runs on SIGINT / SIGTERM
};Flow: load main.ts -> await init() -> hold services -> adapter serves -> teardown on
exit. init runs once and is not re-run on watch rebuilds. The returned object reaches every
handler as a typed c.app, inferred from init's return - no declaration needed.
| Command | What it does |
|---|---|
giri init |
Scaffold giri.config.ts, a starter route, tsconfig paths, and .gitignore. |
giri sync |
Scan src/routes and regenerate .giri/ (manifest, param types, openapi.json). |
giri serve |
sync, run init(), then serve via the adapter. Watches src/ and re-syncs. |
giri build |
Planned - currently a no-op. |
giri serve flags: --port <n>, --host <addr>, --no-watch.
Everything derived lives in .giri/ at the project root: param .d.ts per route, the route
manifest, and the assembled openapi.json. It is gitignored and rebuilt on demand - never edit
it, only import from it.
See example/ for a runnable Hono app:
cd example
yarn install
yarn sync
yarn dev@boon4681/giri/runtime is the portable composition layer for generated integrations. It does
not implement routing itself: it accepts a complete GiriAdapter, creates that adapter's app, and
registers statically imported route modules. Hono is the supported adapter today:
import { createApp } from "@boon4681/giri/runtime";
import { hono } from "@boon4681/giri/adapters/hono";
import * as rootShared from "./src/routes/+shared";
import * as usersGet from "./src/routes/users/+get";
export const app = createApp({
adapter: hono(),
services: { source: "playground" },
routes: [
{
method: "GET",
path: "/api/users",
module: usersGet,
shared: [rootShared],
},
],
});The result is a normal Fetch application:
const response = await app.fetch(new Request("https://example.test/api/users"));app is the native Hono application, so SvelteKit or Next.js can forward a framework request
directly to app.fetch(request). This is the low-level target for virtual modules such as
@giri/project-1; the Vite integration should generate the route descriptor array.
The shipped Hono adapter also owns its Node server binding. A browser playground should provide a
different GiriAdapter implementation whose createApp, register, fetch, and serve methods
bind to the desired browser runtime; createApp itself does not special-case that environment.
MIT
