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

+
+ + +
+ +
+ ); +} 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 + } ] }