diff --git a/.changeset/gold-windows-shine.md b/.changeset/gold-windows-shine.md
new file mode 100644
index 0000000..1fddcee
--- /dev/null
+++ b/.changeset/gold-windows-shine.md
@@ -0,0 +1,5 @@
+---
+"@farbenmeer/router": minor
+---
+
+add switch component
diff --git a/.changeset/sharp-suns-deny.md b/.changeset/sharp-suns-deny.md
new file mode 100644
index 0000000..93744f7
--- /dev/null
+++ b/.changeset/sharp-suns-deny.md
@@ -0,0 +1,5 @@
+---
+"@farbenmeer/tapi": patch
+---
+
+add explicit void response feature to TResponse api
diff --git a/.changeset/silent-beans-pick.md b/.changeset/silent-beans-pick.md
new file mode 100644
index 0000000..22c5ed1
--- /dev/null
+++ b/.changeset/silent-beans-pick.md
@@ -0,0 +1,5 @@
+---
+"@farbenmeer/tapi": minor
+---
+
+patch, put, delete methods
diff --git a/examples/todo-list/.gitignore b/examples/todo-list/.gitignore
new file mode 100644
index 0000000..aea62ad
--- /dev/null
+++ b/examples/todo-list/.gitignore
@@ -0,0 +1,40 @@
+# dependencies (bun install)
+node_modules
+
+# output
+out
+dist
+*.tgz
+
+# code coverage
+coverage
+*.lcov
+
+# logs
+logs
+_.log
+report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
+
+# dotenv environment variable files
+.env
+.env.development.local
+.env.test.local
+.env.production.local
+.env.local
+
+# caches
+.eslintcache
+.cache
+*.tsbuildinfo
+
+# IntelliJ based IDEs
+.idea
+
+# Finder (MacOS) folder config
+.DS_Store
+
+# Bunny database file and build output
+db.sqlite
+.bunny
+
+todos.sqlite
diff --git a/examples/todo-list/CHANGELOG.md b/examples/todo-list/CHANGELOG.md
new file mode 100644
index 0000000..73db958
--- /dev/null
+++ b/examples/todo-list/CHANGELOG.md
@@ -0,0 +1,72 @@
+# @farbenmeer/bunny-boilerplate
+
+## 0.2.2
+
+### Patch Changes
+
+- Updated dependencies [b4f73d8]
+ - @farbenmeer/bunny@0.3.0
+
+## 0.2.1
+
+### Patch Changes
+
+- 3196826: add instructions for setting up tailwind
+ - @farbenmeer/bunny@0.2.4
+
+## 0.2.0
+
+### Minor Changes
+
+- bb41e24: move to pnpm
+
+### Patch Changes
+
+- Updated dependencies [bb41e24]
+ - @farbenmeer/bunny@0.2.0
+
+## 0.1.6
+
+### Patch Changes
+
+- 8620659: Bunny re-exports all the internal packages
+- Updated dependencies [8620659]
+ - @farbenmeer/bunny@0.1.4
+
+## 0.1.5
+
+### Patch Changes
+
+- Updated dependencies [c3e9eaa]
+ - @farbenmeer/bun-auth@0.1.3
+
+## 0.1.4
+
+### Patch Changes
+
+- Updated dependencies [13eb226]
+ - @farbenmeer/bunny@0.1.3
+
+## 0.1.3
+
+### Patch Changes
+
+- Updated dependencies [2578a14]
+- Updated dependencies [3d47664]
+- Updated dependencies [4140811]
+ - @farbenmeer/bunny@0.1.2
+ - @farbenmeer/bun-auth@0.1.2
+
+## 0.1.2
+
+### Patch Changes
+
+- Updated dependencies [857aead]
+ - @farbenmeer/bunny@0.1.1
+
+## 0.1.1
+
+### Patch Changes
+
+- Updated dependencies [ef420c7]
+ - @farbenmeer/bun-auth@0.1.1
diff --git a/examples/todo-list/Dockerfile b/examples/todo-list/Dockerfile
new file mode 100644
index 0000000..62e91fd
--- /dev/null
+++ b/examples/todo-list/Dockerfile
@@ -0,0 +1,23 @@
+# use the official Bun image
+# see all versions at https://hub.docker.com/r/oven/bun/tags
+FROM node:24-alpine AS base
+WORKDIR /usr/src/app
+ENV NODE_ENV=production
+
+# install dependencies into temp directory
+# this will cache them and speed up future builds
+FROM base AS builder
+COPY package.json pnpm-lock.yaml ./
+RUN pnpm install --frozen-lockfile
+
+COPY . .
+RUN pnpm run build --standalone
+
+# copy production dependencies and source code into final image
+FROM base AS runner
+COPY --from=builder /usr/src/app/.bunny/prod .
+
+# run the app
+USER node
+EXPOSE 3000/tcp
+CMD [ "node", "server.js" ]
diff --git a/examples/todo-list/README.md b/examples/todo-list/README.md
new file mode 100644
index 0000000..78bf250
--- /dev/null
+++ b/examples/todo-list/README.md
@@ -0,0 +1,12 @@
+# Bunny Project
+
+This project was created using `@farbenmeer/bunny init`.
+
+It uses the `@farbenmeer/bunny`-Framework.
+
+Commands:
+* `bun run dev` starts the dev-server.
+* `bun run build` creates a production build.
+* `bun run start` starts the production server (run `bun run build` first).
+* `bun run generate` generates a migration based on the drizzle schema in `src/lib/schema.ts`
+* `bun run migrate` to apply the migrations
diff --git a/examples/todo-list/package.json b/examples/todo-list/package.json
new file mode 100644
index 0000000..eacf0bb
--- /dev/null
+++ b/examples/todo-list/package.json
@@ -0,0 +1,32 @@
+{
+ "name": "@farbenmeer/bunny-boilerplate",
+ "version": "0.2.2",
+ "type": "module",
+ "private": true,
+ "files": [
+ "src",
+ ".gitignore",
+ "Dockerfile",
+ "tsconfig.json"
+ ],
+ "scripts": {
+ "dev": "bunny dev",
+ "build": "bunny build",
+ "start": "bunny start"
+ },
+ "dependencies": {
+ "@farbenmeer/bunny": "workspace:^",
+ "zod": "^4",
+ "react": "^19",
+ "react-dom": "^19"
+ },
+ "devDependencies": {
+ "@types/node": "^25.0.3",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
+ "tailwindcss": "^4"
+ },
+ "peerDependencies": {
+ "typescript": "^5"
+ }
+}
diff --git a/examples/todo-list/src/api.ts b/examples/todo-list/src/api.ts
new file mode 100644
index 0000000..88e62b7
--- /dev/null
+++ b/examples/todo-list/src/api.ts
@@ -0,0 +1,5 @@
+import { defineApi } from "@farbenmeer/bunny/server";
+
+export const api = defineApi()
+ .route("/todos", import("./api/todos"))
+ .route("/todos/:id", import("./api/todo"));
diff --git a/examples/todo-list/src/api/todo.ts b/examples/todo-list/src/api/todo.ts
new file mode 100644
index 0000000..c31bfe7
--- /dev/null
+++ b/examples/todo-list/src/api/todo.ts
@@ -0,0 +1,38 @@
+import { defineHandler, TResponse } from "@farbenmeer/bunny/server";
+import { db } from "db";
+import z from "zod";
+
+const params = {
+ id: z.string(),
+};
+
+export const DELETE = defineHandler(
+ {
+ params,
+ authorize: () => true,
+ },
+ async (req) => {
+ db.prepare("DELETE FROM todos WHERE id = ?").run(req.params().id);
+
+ return TResponse.void({ tags: ["todos"] });
+ }
+);
+
+export const PATCH = defineHandler(
+ {
+ params,
+ authorize: () => true,
+ body: z.object({
+ done: z.stringbool().optional(),
+ }),
+ },
+ async (req) => {
+ const { done } = await req.data();
+ db.prepare("UPDATE todos SET done = ? WHERE id = ?").run(
+ done ? 1 : 0,
+ req.params().id
+ );
+
+ return TResponse.void({ tags: ["todos"] });
+ }
+);
diff --git a/examples/todo-list/src/api/todos.ts b/examples/todo-list/src/api/todos.ts
new file mode 100644
index 0000000..3ef2844
--- /dev/null
+++ b/examples/todo-list/src/api/todos.ts
@@ -0,0 +1,33 @@
+import { defineHandler, TResponse } from "@farbenmeer/bunny/server";
+import { db } from "db";
+import { z } from "zod";
+
+const todo = z.object({
+ id: z.number(),
+ text: z.string(),
+ done: z.number().transform(Boolean),
+});
+
+export const GET = defineHandler(
+ {
+ authorize: () => true,
+ },
+ async () => {
+ const todos = todo.array().parse(db.prepare("SELECT * FROM todos").all());
+ return TResponse.json(todos, { tags: ["todos"] });
+ }
+);
+
+export const POST = defineHandler(
+ {
+ authorize: () => true,
+ body: z.object({
+ text: z.string().min(1),
+ }),
+ },
+ async (req) => {
+ const { text } = await req.data();
+ db.prepare("INSERT INTO todos (text, done) VALUES (?, ?)").run(text, 0);
+ return TResponse.void({ tags: ["todos"] });
+ }
+);
diff --git a/examples/todo-list/src/app/app.tsx b/examples/todo-list/src/app/app.tsx
new file mode 100644
index 0000000..68f3e83
--- /dev/null
+++ b/examples/todo-list/src/app/app.tsx
@@ -0,0 +1,37 @@
+import { useQuery } from "@farbenmeer/bunny/client";
+import { client } from "client";
+
+export function App() {
+ const todos = useQuery(client.todos.get());
+
+ return (
+
+
Bunny TODO List
+
+
+ {todos.map((todo) => (
+ -
+
+ {todo.text}
+
+
+ ))}
+
+
+ );
+}
diff --git a/examples/todo-list/src/client.ts b/examples/todo-list/src/client.ts
new file mode 100644
index 0000000..8f73a9f
--- /dev/null
+++ b/examples/todo-list/src/client.ts
@@ -0,0 +1,4 @@
+import { createFetchClient } from "@farbenmeer/bunny/client";
+import type { api } from "./api";
+
+export const client = createFetchClient("/api");
diff --git a/examples/todo-list/src/db.ts b/examples/todo-list/src/db.ts
new file mode 100644
index 0000000..93bd4d4
--- /dev/null
+++ b/examples/todo-list/src/db.ts
@@ -0,0 +1,11 @@
+import { DatabaseSync } from "node:sqlite";
+
+export const db = new DatabaseSync("todos.sqlite");
+
+db.exec(`
+ CREATE TABLE IF NOT EXISTS todos (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ text TEXT NOT NULL,
+ done BOOLEAN NOT NULL DEFAULT 0
+ )
+`);
diff --git a/examples/todo-list/src/index.html b/examples/todo-list/src/index.html
new file mode 100644
index 0000000..d961916
--- /dev/null
+++ b/examples/todo-list/src/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+ Bunny
+
+
+
+
+
+
diff --git a/examples/todo-list/src/index.tsx b/examples/todo-list/src/index.tsx
new file mode 100644
index 0000000..c8d2677
--- /dev/null
+++ b/examples/todo-list/src/index.tsx
@@ -0,0 +1,11 @@
+/**
+ * This file is the entry point for the React app, it sets up the root
+ * element and renders the App component to the DOM.
+ *
+ * It is included in `src/index.html`.
+ */
+
+import { startBunnyClient } from "@farbenmeer/bunny/client";
+import { App } from "app/app";
+
+startBunnyClient();
diff --git a/examples/todo-list/src/main.css b/examples/todo-list/src/main.css
new file mode 100644
index 0000000..1189ed8
--- /dev/null
+++ b/examples/todo-list/src/main.css
@@ -0,0 +1,26 @@
+ul {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ max-width: 24rem;
+ padding: 0;
+}
+
+li {
+ background-color: lightblue;
+ display: flex;
+ border-radius: 1rem;
+ padding: 1rem;
+ gap: 0.5rem;
+ align-items: center;
+ justify-content: start;
+}
+
+button {
+ background-color: white;
+ border: gray 1px solid;
+}
+
+.delete-form {
+ margin-left: auto;
+}
diff --git a/examples/todo-list/src/types.d.ts b/examples/todo-list/src/types.d.ts
new file mode 100644
index 0000000..66b2456
--- /dev/null
+++ b/examples/todo-list/src/types.d.ts
@@ -0,0 +1,4 @@
+declare module "*.png" {
+ const value: string;
+ export default value;
+}
diff --git a/examples/todo-list/tsconfig.json b/examples/todo-list/tsconfig.json
new file mode 100644
index 0000000..1c08d1b
--- /dev/null
+++ b/examples/todo-list/tsconfig.json
@@ -0,0 +1,31 @@
+{
+ "compilerOptions": {
+ // Environment setup & latest features
+ "lib": ["ESNext", "DOM"],
+ "target": "ESNext",
+ "module": "Preserve",
+ "moduleDetection": "force",
+ "jsx": "react-jsx",
+ "allowJs": true,
+ "baseUrl": "./src",
+
+ // Bundler mode
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "noEmit": true,
+
+ // Best practices
+ "strict": true,
+ "skipLibCheck": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedIndexedAccess": true,
+ "noImplicitOverride": true,
+
+ // Some stricter flags (disabled by default)
+ "noUnusedLocals": false,
+ "noUnusedParameters": false,
+ "noPropertyAccessFromIndexSignature": false,
+ },
+ "include": ["./src"],
+}
diff --git a/packages/1-router/src/context.ts b/packages/1-router/src/context.ts
index 546dab9..8692977 100644
--- a/packages/1-router/src/context.ts
+++ b/packages/1-router/src/context.ts
@@ -17,11 +17,13 @@ export const RouterContext = createContext<{
replace: () => {},
});
-export const RouteContext = createContext<{
+export interface RouteContextValue {
path: string;
params: Record;
matchedPathname: string;
-}>({
+}
+
+export const RouteContext = createContext({
path: "/",
params: {},
matchedPathname: "/",
diff --git a/packages/1-router/src/path.ts b/packages/1-router/src/path.ts
index 643b684..34c0b6a 100644
--- a/packages/1-router/src/path.ts
+++ b/packages/1-router/src/path.ts
@@ -29,3 +29,49 @@ export function removeTrailingSlash(path: string): string {
if (path === "/") return path;
return path.endsWith("/") ? path.slice(0, -1) : path;
}
+
+export function compilePathRegex(path: string): RegExp {
+ if (path === "/") {
+ return /^\//;
+ }
+ // Handle wildcards: *name captures as named group, * catches all without capturing
+ const pattern = path
+ .replaceAll(/\*(\w+)/g, "(?<$1>.+)") // *name -> named capture group
+ .replaceAll(/\*/g, ".+") // * -> match everything including /
+ .replaceAll(/:(\w+)/g, "(?<$1>[\\w-]+)"); // :param -> named capture group
+
+ // If pattern contains a wildcard, it already matches everything - use exact match
+ if (path.includes("*")) {
+ return new RegExp(`^(${pattern})$`);
+ }
+
+ // For non-wildcard paths, allow optional trailing paths
+ return new RegExp(`^(${pattern})(/.*)?$`);
+}
+
+export function compileExactPathRegex(path: string): RegExp {
+ if (path === "/") {
+ return /^\/$/;
+ }
+ // Handle wildcards: *name captures as named group, * catches all without capturing
+ const pattern = path
+ .replaceAll(/\*(\w+)/g, "(?<$1>.+)") // *name -> named capture group
+ .replaceAll(/\*/g, ".+") // * -> match everything including /
+ .replaceAll(/:(\w+)/g, "(?<$1>[\\w-]+)"); // :param -> named capture group
+ return new RegExp(`^(${pattern})$`);
+}
+
+export function buildFullPath(parentPath: string, path?: string) {
+ if (path?.startsWith("/")) {
+ return path;
+ }
+
+ if (path) {
+ if (parentPath === "/") {
+ return "/" + path;
+ }
+ return parentPath + "/" + path;
+ }
+
+ return parentPath;
+}
diff --git a/packages/1-router/src/route.tsx b/packages/1-router/src/route.tsx
index 1bc0d11..d8b389c 100644
--- a/packages/1-router/src/route.tsx
+++ b/packages/1-router/src/route.tsx
@@ -1,13 +1,14 @@
import { use, useMemo } from "react";
import { PathnameContext, RouteContext } from "./context";
+import { buildFullPath, compileExactPathRegex, compilePathRegex } from "./path";
-interface Props {
+export interface RouteProps {
path?: string;
exact?: boolean;
children: React.ReactNode;
}
-export function Route({ path, exact, children }: Props) {
+export function Route({ path, exact, children }: RouteProps) {
const parentRoute = use(RouteContext);
const pathname = use(PathnameContext);
const fullPath = useMemo(
@@ -34,49 +35,3 @@ export function Route({ path, exact, children }: Props) {
return {children};
}
-
-function compilePathRegex(path: string): RegExp {
- if (path === "/") {
- return /^\//;
- }
- // Handle wildcards: *name captures as named group, * catches all without capturing
- const pattern = path
- .replaceAll(/\*(\w+)/g, "(?<$1>.+)") // *name -> named capture group
- .replaceAll(/\*/g, ".+") // * -> match everything including /
- .replaceAll(/:(\w+)/g, "(?<$1>[\\w-]+)"); // :param -> named capture group
-
- // If pattern contains a wildcard, it already matches everything - use exact match
- if (path.includes("*")) {
- return new RegExp(`^(${pattern})$`);
- }
-
- // For non-wildcard paths, allow optional trailing paths
- return new RegExp(`^(${pattern})(/.*)?$`);
-}
-
-function compileExactPathRegex(path: string): RegExp {
- if (path === "/") {
- return /^\/$/;
- }
- // Handle wildcards: *name captures as named group, * catches all without capturing
- const pattern = path
- .replaceAll(/\*(\w+)/g, "(?<$1>.+)") // *name -> named capture group
- .replaceAll(/\*/g, ".+") // * -> match everything including /
- .replaceAll(/:(\w+)/g, "(?<$1>[\\w-]+)"); // :param -> named capture group
- return new RegExp(`^(${pattern})$`);
-}
-
-function buildFullPath(parentPath: string, path?: string) {
- if (path?.startsWith("/")) {
- return path;
- }
-
- if (path) {
- if (parentPath === "/") {
- return "/" + path;
- }
- return parentPath + "/" + path;
- }
-
- return parentPath;
-}
diff --git a/packages/1-router/src/switch.test.tsx b/packages/1-router/src/switch.test.tsx
new file mode 100644
index 0000000..c800a7f
--- /dev/null
+++ b/packages/1-router/src/switch.test.tsx
@@ -0,0 +1,49 @@
+import { describe, expect, test } from "vitest";
+import { render } from "vitest-browser-react";
+import { Route } from "./route";
+import { useParams } from "./use-params";
+import { Router } from "./router";
+import { mockHistory } from "./mock-history";
+import { usePathname } from "./use-pathname";
+import { Switch } from "./switch";
+
+describe("Switch", () => {
+ describe("basic routing", () => {
+ test("renders home route by default", async () => {
+ const screen = await render(
+
+
+ Home
+
+
+ );
+
+ await expect.element(screen.getByText("Home")).toBeInTheDocument();
+ });
+
+ test("renders first matching route", async () => {
+ const screen = await render(
+
+ Foo
+ Bar
+ Baz
+
+ );
+
+ const container = screen.container;
+ expect(container).toHaveTextContent("Bar");
+ });
+
+ test("renders route without path as fallback", async () => {
+ const screen = await render(
+
+ Foo
+ Bar
+ Baz
+
+ );
+
+ await expect.element(screen.container).toHaveTextContent("Baz");
+ });
+ });
+});
diff --git a/packages/1-router/src/switch.tsx b/packages/1-router/src/switch.tsx
new file mode 100644
index 0000000..a37e2f1
--- /dev/null
+++ b/packages/1-router/src/switch.tsx
@@ -0,0 +1,67 @@
+import { Children, use, useMemo, type ReactElement } from "react";
+import type { Route, RouteProps } from "./route";
+import {
+ PathnameContext,
+ RouteContext,
+ type RouteContextValue,
+} from "./context";
+import { buildFullPath, compileExactPathRegex, compilePathRegex } from "./path";
+
+interface Props {
+ children:
+ | ReactElement
+ | ReactElement[];
+}
+
+export function Switch({ children }: Props) {
+ const parentRoute = use(RouteContext);
+ const pathname = use(PathnameContext);
+ const props = Children.map(children, (route) => route.props);
+
+ const routeMeta = useMemo(
+ () =>
+ props.map((route) => {
+ const path = route.path ?? "";
+ const fullPath = buildFullPath(parentRoute.path, path);
+ return {
+ path,
+ fullPath,
+ pathRegex: route.exact
+ ? compileExactPathRegex(fullPath)
+ : compilePathRegex(fullPath),
+ };
+ }),
+ [
+ parentRoute.path,
+ props.map((route) => (route.exact ? "e" : "l" + route.path)).join(" "),
+ ]
+ );
+
+ const match = useMemo((): [string, RouteContextValue] | null => {
+ for (const meta of routeMeta) {
+ const match = pathname.match(meta.pathRegex);
+ if (match)
+ return [
+ meta.path,
+ {
+ path: meta.fullPath,
+ params: match?.groups ?? {},
+ matchedPathname: match?.[1] ?? "",
+ },
+ ];
+ }
+ return null;
+ }, [routeMeta]);
+
+ if (!match) {
+ return null;
+ }
+
+ const [path, context] = match;
+
+ return (
+
+ {props.find((route) => route.path === path)?.children}
+
+ );
+}
diff --git a/packages/1-tapi/src/client/client-types.ts b/packages/1-tapi/src/client/client-types.ts
index 96d276b..d4f8b7e 100644
--- a/packages/1-tapi/src/client/client-types.ts
+++ b/packages/1-tapi/src/client/client-types.ts
@@ -30,13 +30,16 @@ export type Client>> = {
};
type ClientRoute> = {
- get: GetRoute["GET"]>;
+ get: RouteWithoutBody["GET"]>;
post: RouteWithBody["POST"]>;
+ delete: RouteWithoutBody["DELETE"]>;
+ put: RouteWithBody["PUT"]>;
+ patch: RouteWithBody["PATCH"]>;
revalidate: () => Promise;
};
-export type GetRoute<
- Handler extends BaseHandler | undefined
+export type RouteWithoutBody<
+ Handler extends BaseHandler | undefined
> = Handler extends undefined
? never
: keyof QueryType extends never
@@ -53,17 +56,6 @@ export type Observable = {
subscribe(callback: (value: Promise) => void): () => void;
};
-export type RouteWithoutBody<
- Handler extends BaseHandler | undefined
-> = Handler extends undefined
- ? never
- : keyof QueryType extends never
- ? (query?: {}, req?: RequestInit) => Promise>
- : (
- query: QueryType,
- req?: RequestInit
- ) => Promise>;
-
export type RouteWithBody<
Handler extends BaseHandler | undefined
> = Handler extends undefined
diff --git a/packages/1-tapi/src/client/handle-response.ts b/packages/1-tapi/src/client/handle-response.ts
index 927a8ca..34c8bea 100644
--- a/packages/1-tapi/src/client/handle-response.ts
+++ b/packages/1-tapi/src/client/handle-response.ts
@@ -25,12 +25,18 @@ export async function handleResponse(res: Response) {
async function parseBody(res: Response) {
const contentType = res.headers.get("Content-Type");
- switch (contentType) {
- case "application/json":
- return await res.json();
- case "text/plain":
- return await res.text();
- default:
- throw new Error(`Tapi: Unsupported content type: ${contentType}`);
+ if (contentType?.startsWith("application/json")) {
+ return await res.json();
+ }
+
+ if (contentType?.startsWith("text/plain")) {
+ return await res.text();
}
+
+ const contentLength = res.headers.get("Content-Length");
+ if (contentLength === "0") {
+ return undefined;
+ }
+
+ throw new Error(`Tapi: Unsupported content type: ${contentType}`);
}
diff --git a/packages/1-tapi/src/server/create-request-handler.ts b/packages/1-tapi/src/server/create-request-handler.ts
index 4babd88..f9549cb 100644
--- a/packages/1-tapi/src/server/create-request-handler.ts
+++ b/packages/1-tapi/src/server/create-request-handler.ts
@@ -34,16 +34,18 @@ export function createRequestHandler(
if (match) {
const params = match.groups || {};
switch (req.method) {
- case "GET": {
- if (!route.GET) return new Response("Not Found", { status: 404 });
+ case "GET":
+ case "DELETE": {
+ const handler = route[req.method];
+ if (!handler) return new Response("Not Found", { status: 404 });
try {
const treq = await prepareRequestWithoutBody(
- route.GET,
+ handler,
url,
params,
req
);
- return await executeHandler(route.GET, treq);
+ return await executeHandler(handler, treq);
} catch (error) {
return handleError(
options.hooks?.error ? await options.hooks.error(error) : error
@@ -51,24 +53,28 @@ export function createRequestHandler(
}
}
case "POST":
- if (!route.POST)
+ case "PUT":
+ case "PATCH": {
+ const handler = route[req.method];
+ if (!handler)
return new Response("Not Found", {
status: 404,
statusText: "Not Found",
});
try {
const treq = await prepareRequestWithBody(
- route.POST,
+ handler,
url,
params,
req
);
- return await executeHandler(route.POST, treq);
+ return await executeHandler(handler, treq);
} catch (error) {
return handleError(
options.hooks?.error ? await options.hooks?.error(error) : error
);
}
+ }
default:
return new Response("Not Found", {
status: 404,
@@ -201,7 +207,7 @@ export async function executeHandler(
function handleError(error: unknown) {
if (error instanceof ZodError) {
- return new Response(JSON.stringify(error.issues), {
+ return Response.json(error.issues, {
status: 400,
headers: {
"Content-Type": "application/json+zodissues",
@@ -209,11 +215,11 @@ function handleError(error: unknown) {
});
}
if (error instanceof HttpError) {
- return new Response(
- JSON.stringify({
+ return Response.json(
+ {
message: error.message,
data: error.data,
- }),
+ },
{
status: error.status,
headers: {
diff --git a/packages/1-tapi/src/server/define-api.mock.ts b/packages/1-tapi/src/server/define-api.mock.ts
index 9b824b1..5edfd2b 100644
--- a/packages/1-tapi/src/server/define-api.mock.ts
+++ b/packages/1-tapi/src/server/define-api.mock.ts
@@ -95,4 +95,36 @@ export const api = defineApi()
},
async (req) => TResponse.json({ pathname: req.url })
),
+ })
+ .route("/method", {
+ GET: defineHandler(
+ {
+ authorize: () => true,
+ },
+ async () => TResponse.json({ method: "GET" })
+ ),
+ POST: defineHandler(
+ {
+ authorize: () => true,
+ },
+ async () => TResponse.json({ method: "POST" })
+ ),
+ PUT: defineHandler(
+ {
+ authorize: () => true,
+ },
+ async () => TResponse.json({ method: "PUT" })
+ ),
+ PATCH: defineHandler(
+ {
+ authorize: () => true,
+ },
+ async () => TResponse.json({ method: "PATCH" })
+ ),
+ DELETE: defineHandler(
+ {
+ authorize: () => true,
+ },
+ async () => TResponse.json({ method: "DELETE" })
+ ),
});
diff --git a/packages/1-tapi/src/server/define-api.ts b/packages/1-tapi/src/server/define-api.ts
index be49104..bc8ee32 100644
--- a/packages/1-tapi/src/server/define-api.ts
+++ b/packages/1-tapi/src/server/define-api.ts
@@ -15,7 +15,15 @@ export class ApiDefinition> {
GetQuery extends Record = never,
PostResponse = never,
PostQuery extends Record = never,
- PostBody = never
+ PostBody = never,
+ DeleteResponse = never,
+ DeleteQuery extends Record = never,
+ PutResponse = never,
+ PutQuery extends Record = never,
+ PutBody = never,
+ PatchResponse = never,
+ PatchQuery extends Record = never,
+ PatchBody = never
>(
path: Path,
route: MaybePromise<
@@ -25,7 +33,15 @@ export class ApiDefinition> {
GetQuery,
PostResponse,
PostQuery,
- PostBody
+ PostBody,
+ DeleteResponse,
+ DeleteQuery,
+ PutResponse,
+ PutQuery,
+ PutBody,
+ PatchResponse,
+ PatchQuery,
+ PatchBody
>
>
) {
@@ -39,7 +55,15 @@ export class ApiDefinition> {
GetQuery,
PostResponse,
PostQuery,
- PostBody
+ PostBody,
+ DeleteResponse,
+ DeleteQuery,
+ PutResponse,
+ PutQuery,
+ PutBody,
+ PatchResponse,
+ PatchQuery,
+ PatchBody
>
>;
}
diff --git a/packages/1-tapi/src/server/define-handler.ts b/packages/1-tapi/src/server/define-handler.ts
index 9da27b4..6005db1 100644
--- a/packages/1-tapi/src/server/define-handler.ts
+++ b/packages/1-tapi/src/server/define-handler.ts
@@ -6,7 +6,7 @@ export function defineHandler<
AuthData,
Params extends Record = {},
Query extends Record = {},
- Body = never
+ Body = undefined
>(
schema: Schema,
handler: HandlerFn
diff --git a/packages/1-tapi/src/server/t-response.ts b/packages/1-tapi/src/server/t-response.ts
index be0142d..0029671 100644
--- a/packages/1-tapi/src/server/t-response.ts
+++ b/packages/1-tapi/src/server/t-response.ts
@@ -5,14 +5,32 @@ interface TResponseInit extends ResponseInit {
export class TResponse extends Response {
public data?: T;
- static override json(data: T, init?: TResponseInit): TResponse {
- const headers = new Headers(init?.headers as HeadersInit);
- headers.set("Content-Type", "application/json");
- if (init?.tags) {
- headers.set("X-TAPI-Tags", init.tags.join(" "));
+ constructor(body: BodyInit | null = null, init: TResponseInit = {}) {
+ const { tags, ...rawInit } = init;
+ super(body, rawInit);
+ if (tags) {
+ this.headers.append("X-TAPI-Tags", tags?.join(" "));
}
- const res = new TResponse(JSON.stringify(data), { ...init, headers });
+ }
+
+ static override json(data: T, init: TResponseInit = {}): TResponse {
+ setHeader(init, "Content-Type", "application/json");
+ const res = new TResponse(JSON.stringify(data), init);
res.data = data;
return res;
}
+
+ static void(init: TResponseInit = {}): TResponse {
+ setHeader(init, "Content-Length", "0");
+ const res = new TResponse(null, init);
+ res.data = undefined;
+ return res;
+ }
+}
+
+function setHeader(init: TResponseInit, key: string, value: string) {
+ init.headers = new Headers(init.headers);
+ if (!init.headers.has(key)) {
+ init.headers.set(key, value);
+ }
}
diff --git a/packages/1-tapi/src/shared/route.ts b/packages/1-tapi/src/shared/route.ts
index e5927f7..bec30e3 100644
--- a/packages/1-tapi/src/shared/route.ts
+++ b/packages/1-tapi/src/shared/route.ts
@@ -6,17 +6,37 @@ export type Route<
GetQuery extends Record,
PostResponse,
PostQuery extends Record,
- PostBody
+ PostBody,
+ DeleteResponse,
+ DeleteQuery extends Record,
+ PutResponse,
+ PutQuery extends Record,
+ PutBody,
+ PatchResponse,
+ PatchQuery extends Record,
+ PatchBody
> = {
GET?: GetQuery extends never
? never
- : Handler;
+ : Handler;
POST?: PostQuery extends never
? never
: Handler;
+ DELETE?: DeleteQuery extends never
+ ? never
+ : Handler;
+ PUT?: PutQuery extends never
+ ? never
+ : Handler;
+ PATCH?: PatchQuery extends never
+ ? never
+ : Handler;
};
export type BaseRoute = {
- GET?: Handler;
+ GET?: Handler;
POST?: Handler;
+ DELETE?: Handler;
+ PUT?: Handler;
+ PATCH?: Handler;
};
diff --git a/packages/3-bunny/src/cli/dev.ts b/packages/3-bunny/src/cli/dev.ts
index d6aa3d8..8acd407 100644
--- a/packages/3-bunny/src/cli/dev.ts
+++ b/packages/3-bunny/src/cli/dev.ts
@@ -39,6 +39,7 @@ export const dev = new Command()
...config.vite?.server,
},
plugins: [...(config.vite?.plugins ?? []), viteTsconfigPaths()],
+ clearScreen: false,
});
const app = connect();
@@ -47,7 +48,7 @@ export const dev = new Command()
let openAPISchema: string;
async function reload() {
const { api } = await import(
- path.resolve(bunnyDir, `api.cjs?ts=${Date.now()}`)
+ path.resolve(bunnyDir, `api.cjs`) + "?ts=" + Date.now()
);
apiRequestHandler = createRequestHandler(api, {
basePath: "/api",
@@ -82,7 +83,14 @@ export const dev = new Command()
{
name: "bunny-hot-reload",
setup(build) {
- build.onEnd(async () => {
+ build.onEnd(async (result) => {
+ console.log("Bunny: Hot-Reload Server");
+ result.warnings.forEach((warning) => {
+ console.warn("Bunny Server:", warning);
+ });
+ result.errors.forEach((error) => {
+ console.error("Bunny Server:", error);
+ });
await reload();
});
},
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index be576da..575e590 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -40,6 +40,37 @@ importers:
specifier: ^19
version: 19.2.3(@types/react@19.2.7)
+ examples/todo-list:
+ dependencies:
+ '@farbenmeer/bunny':
+ specifier: workspace:^
+ version: link:../../packages/3-bunny
+ react:
+ specifier: ^19
+ version: 19.2.3
+ react-dom:
+ specifier: ^19
+ version: 19.2.3(react@19.2.3)
+ typescript:
+ specifier: ^5
+ version: 5.9.3
+ zod:
+ specifier: ^4
+ version: 4.3.5
+ devDependencies:
+ '@types/node':
+ specifier: ^25.0.3
+ version: 25.0.3
+ '@types/react':
+ specifier: ^19
+ version: 19.2.7
+ '@types/react-dom':
+ specifier: ^19
+ version: 19.2.3(@types/react@19.2.7)
+ tailwindcss:
+ specifier: ^4
+ version: 4.1.18
+
packages/1-lacy:
dependencies:
typescript:
diff --git a/renovate.json b/renovate.json
index 5db72dd..e95e20d 100644
--- a/renovate.json
+++ b/renovate.json
@@ -1,6 +1,19 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
- "extends": [
- "config:recommended"
+ "extends": ["config:recommended"],
+ "prConcurrentLimit": 5,
+ "rebaseWhen": "conflicted",
+ "minimumReleaseAge": "1 day",
+ "packageRules": [
+ {
+ "matchUpdateTypes": [
+ "minor",
+ "patch",
+ "pin",
+ "digest",
+ "lockFileMaintenance"
+ ],
+ "automerge": true
+ }
]
}