@boon4681/giri
v0.0.3-alpha-20
Published
A stupid attempt from a stupid man who lack of foresight trying to make a backend framework.
Readme
Giri
A stupid attempt from a stupid man who lack of foresight trying to make a backend framework.
Why does giri exist?
Because I can and i am too lazy to write OpenAPI spec.
Status: early and experimental. Hono is the only adapter today; the API will change.
Install
NPM:
npm install @boon4681/giriYarn:
yarn add @boon4681/girigiri init installs TypeScript 5.9 by default because the TypeScript 6 compatibility package currently fails under Yarn's built-in TypeScript patch.
Quick start
mkdir my-giri-app
cd my-giri-app
npm init -y
npx @boon4681/giri init --pm npm -y
npx giri serveserve generates .giri/ before starting and watches the source tree. Run
npx giri sync --no-watch when you only want to regenerate the manifest, route types, and
OpenAPI document once.
Config
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/config";
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
});Routes
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 }The context c
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).
Inputs
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.
Responses
Declare response shapes once with a route's responses export. The generated method type reads
it automatically, so c.json payloads are checked against the shape for their status code.
import { z } from "zod";
import { zod } from "@boon4681/giri/validators/zod";
import type { GET } from "./$types";
export const responses = {
200: zod.response(z.object({ id: z.string(), name: z.string() })),
404: zod.response(z.object({ message: z.string() })),
};
export const handle: GET = (c) => {
return c.json({ id: "1", name: "Ada" }); // status 200
// return c.json({}); // type error: `id` and `name` are missing
};Use zod.response(...) or valibot.response(...). Response schemas document output and type-check
handlers; Giri does not validate response bodies at runtime.
Middleware
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.
Lifecycle
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.
CLI
giri init [--adapter hono] [--pm npm|yarn|pnpm|bun] [--install|--no-install] [-y]
giri sync [--no-watch]
giri serve [--port 3000] [--host 127.0.0.1] [--no-watch]
giri build [--out dist] [--minify] [--include-deps] [--format esm|cjs] [--no-typecheck]| Command | What it does |
| ------------ | --------------------------------------------------------------------------------------------- |
| giri init | Scaffold a Giri project and optionally install its adapter, validator, and TypeScript tooling. |
| giri sync [--no-watch] | Scan src/routes and regenerate .giri/ once. --no-watch explicitly guarantees one-shot operation for scripts. |
| giri serve | Sync, run the lifecycle init(), start the adapter server, and watch source files. |
| giri build | Sync, then bundle routes, config, lifecycle, and aliases into a runnable dist/index.js. Type errors are shown as warnings. |
Run giri init inside a project that already has a package.json. It creates
giri.config.ts, src/routes/+get.ts, tsconfig.json, and .gitignore when they do not
already exist. Dependency installation includes the supported TypeScript 5.9 release line.
giri init options
| Option | Description |
| ----------------------------------- | ------------------------------------------------------------------ |
| --adapter hono, -a hono | Select the backend adapter. Hono is currently the only adapter. |
| --pm <name>, --package-manager <name> | Use npm, yarn, pnpm, or bun for dependency installation. |
| --install | Install dependencies without prompting. |
| --no-install | Only write the scaffold and print the commands that would be run. |
| --yes, -y | Use defaults non-interactively and install dependencies. |
giri serve options
| Option | Description |
| ------------------------- | -------------------------------------------------------- |
| --port <number>, -p | Override the configured server port. |
| --host <address> | Override the bind address (--hostname is also valid). |
| --no-watch | Start without watching source files for changes. |
giri build options
| Option | Description |
| ------------------------- | --------------------------------------------------------------------------- |
| --out <dir> | Output directory for index.js and its source map. Defaults to dist. |
| --minify | Minify the bundle. |
| --include-deps | Bundle node_modules dependencies too, so the output runs without them. |
| --format esm\|cjs | Output module format. Defaults to the nearest package.json type. |
| --no-typecheck | Skip the TypeScript check that reports type errors as warnings. |
Without --format, the bundle is ESM when the nearest package.json has "type": "module",
otherwise CommonJS. A format that differs from the package type is written as index.mjs or
index.cjs so Node loads it correctly.
It refuses to write into .giri/, src/, or the project root.
Run the output with Node. It accepts the same server flags as giri serve:
node dist/index.js [--port 3000] [--host 127.0.0.1]| Option | Description |
| ------------------------- | -------------------------------------------------------- |
| --port <number>, -p | Override the configured server port. |
| --host <address> | Override the bind address (--hostname is also valid). |
init() runs before the server starts, and teardown() runs on SIGINT/SIGTERM.
Generated output (.giri/)
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.
Example
See example/ for a runnable Hono app:
cd example
yarn install
yarn sync
yarn devPortable runtime
@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.
License
MIT
