diff --git a/examples/vite-plugin-tapi-demo/.gitignore b/examples/vite-plugin-tapi-demo/.gitignore
new file mode 100644
index 0000000..c9a86b9
--- /dev/null
+++ b/examples/vite-plugin-tapi-demo/.gitignore
@@ -0,0 +1 @@
+/test-results
diff --git a/examples/vite-plugin-tapi-demo/e2e/dev/basic.spec.ts b/examples/vite-plugin-tapi-demo/e2e/dev/basic.spec.ts
new file mode 100644
index 0000000..575ba74
--- /dev/null
+++ b/examples/vite-plugin-tapi-demo/e2e/dev/basic.spec.ts
@@ -0,0 +1,27 @@
+import { test, expect } from "@playwright/test";
+
+test("page renders with title", async ({ page }) => {
+ await page.goto("/");
+ await expect(
+ page.getByRole("heading", { name: "vite-plugin-tapi demo" }),
+ ).toBeVisible();
+});
+
+test("greets the default name", async ({ page }) => {
+ await page.goto("/");
+ await page.getByRole("button", { name: "Greet" }).click();
+ await expect(page.getByTestId("output")).toHaveText("hello, world");
+});
+
+test("greets a custom name", async ({ page }) => {
+ await page.goto("/");
+ await page.getByLabel("Name:").fill("claude");
+ await page.getByRole("button", { name: "Greet" }).click();
+ await expect(page.getByTestId("output")).toHaveText("hello, claude");
+});
+
+test("api returns json", async ({ request }) => {
+ const res = await request.get("/greet?name=api");
+ expect(res.status()).toBe(200);
+ expect(await res.json()).toEqual({ greeting: "hello, api" });
+});
diff --git a/examples/vite-plugin-tapi-demo/e2e/prod/basic.spec.ts b/examples/vite-plugin-tapi-demo/e2e/prod/basic.spec.ts
new file mode 100644
index 0000000..575ba74
--- /dev/null
+++ b/examples/vite-plugin-tapi-demo/e2e/prod/basic.spec.ts
@@ -0,0 +1,27 @@
+import { test, expect } from "@playwright/test";
+
+test("page renders with title", async ({ page }) => {
+ await page.goto("/");
+ await expect(
+ page.getByRole("heading", { name: "vite-plugin-tapi demo" }),
+ ).toBeVisible();
+});
+
+test("greets the default name", async ({ page }) => {
+ await page.goto("/");
+ await page.getByRole("button", { name: "Greet" }).click();
+ await expect(page.getByTestId("output")).toHaveText("hello, world");
+});
+
+test("greets a custom name", async ({ page }) => {
+ await page.goto("/");
+ await page.getByLabel("Name:").fill("claude");
+ await page.getByRole("button", { name: "Greet" }).click();
+ await expect(page.getByTestId("output")).toHaveText("hello, claude");
+});
+
+test("api returns json", async ({ request }) => {
+ const res = await request.get("/greet?name=api");
+ expect(res.status()).toBe(200);
+ expect(await res.json()).toEqual({ greeting: "hello, api" });
+});
diff --git a/examples/vite-plugin-tapi-demo/package.json b/examples/vite-plugin-tapi-demo/package.json
new file mode 100644
index 0000000..df3bda6
--- /dev/null
+++ b/examples/vite-plugin-tapi-demo/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "@farbenmeer/vite-plugin-tapi-example-demo",
+ "version": "0.0.0",
+ "type": "module",
+ "private": true,
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "start": "node dist/server.mjs",
+ "test": "playwright test",
+ "test:dev": "playwright test --project=dev",
+ "test:prod": "playwright test --project=prod",
+ "ci-setup": "playwright install --with-deps"
+ },
+ "dependencies": {
+ "@farbenmeer/tapi": "workspace:^",
+ "@farbenmeer/vite-plugin-tapi": "workspace:^",
+ "srvx": "^0.11.15",
+ "vite": "^8.0.9",
+ "zod": "^4.0.17"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.58.1",
+ "@types/node": "^25.0.3"
+ },
+ "peerDependencies": {
+ "typescript": "^5 || ^6.0.0"
+ }
+}
diff --git a/examples/vite-plugin-tapi-demo/playwright.config.ts b/examples/vite-plugin-tapi-demo/playwright.config.ts
new file mode 100644
index 0000000..912d2ed
--- /dev/null
+++ b/examples/vite-plugin-tapi-demo/playwright.config.ts
@@ -0,0 +1,41 @@
+import { defineConfig, devices } from "@playwright/test";
+
+export default defineConfig({
+ testDir: "./e2e",
+ fullyParallel: false,
+ workers: 1,
+ retries: 2,
+ reporter: "list",
+ use: {
+ ...devices["Desktop Chrome"],
+ trace: "on-first-retry",
+ },
+ projects: [
+ {
+ name: "dev",
+ testDir: "./e2e/dev",
+ use: { baseURL: "http://localhost:3200" },
+ },
+ {
+ name: "prod",
+ testDir: "./e2e/prod",
+ use: { baseURL: "http://localhost:3201" },
+ },
+ ],
+ webServer: [
+ {
+ command: "pnpm dev",
+ env: { PORT: "3200" },
+ port: 3200,
+ reuseExistingServer: !process.env.CI,
+ stdout: "pipe",
+ },
+ {
+ command: "pnpm build && pnpm start",
+ env: { PORT: "3201" },
+ port: 3201,
+ reuseExistingServer: !process.env.CI,
+ stdout: "pipe",
+ },
+ ],
+});
diff --git a/examples/vite-plugin-tapi-demo/src/api.ts b/examples/vite-plugin-tapi-demo/src/api.ts
new file mode 100644
index 0000000..903b402
--- /dev/null
+++ b/examples/vite-plugin-tapi-demo/src/api.ts
@@ -0,0 +1,56 @@
+import {
+ defineApi,
+ defineHandler,
+ TResponse,
+} from "@farbenmeer/tapi/server";
+import { z } from "zod";
+
+const html = `
+
+
+
+ vite-plugin-tapi demo
+
+
+ vite-plugin-tapi demo
+
+
+
+
+`;
+
+export const api = defineApi()
+ .route("/", {
+ GET: defineHandler({ authorize: () => true }, async () => {
+ return new TResponse(html, {
+ headers: { "content-type": "text/html; charset=utf-8" },
+ });
+ }),
+ })
+ .route("/greet", {
+ GET: defineHandler(
+ {
+ authorize: () => true,
+ query: { name: z.string().optional() },
+ },
+ async (req) => {
+ const { name = "world" } = req.query();
+ return TResponse.json({ greeting: `hello, ${name}` });
+ },
+ ),
+ });
diff --git a/examples/vite-plugin-tapi-demo/tsconfig.json b/examples/vite-plugin-tapi-demo/tsconfig.json
new file mode 100644
index 0000000..7d2b79f
--- /dev/null
+++ b/examples/vite-plugin-tapi-demo/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "lib": ["ESNext", "DOM"],
+ "target": "ESNext",
+ "module": "Preserve",
+ "moduleDetection": "force",
+ "allowJs": true,
+
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "noEmit": true,
+
+ "strict": true,
+ "skipLibCheck": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedIndexedAccess": true,
+ "noImplicitOverride": true
+ },
+ "include": ["./src", "./e2e", "./vite.config.ts", "./playwright.config.ts"]
+}
diff --git a/examples/vite-plugin-tapi-demo/vite.config.ts b/examples/vite-plugin-tapi-demo/vite.config.ts
new file mode 100644
index 0000000..058d08f
--- /dev/null
+++ b/examples/vite-plugin-tapi-demo/vite.config.ts
@@ -0,0 +1,6 @@
+import { defineConfig } from "vite";
+import tapi from "@farbenmeer/vite-plugin-tapi";
+
+export default defineConfig({
+ plugins: [tapi({ basePath: "" })],
+});
diff --git a/packages/3-vite-plugin-tapi/package.json b/packages/3-vite-plugin-tapi/package.json
new file mode 100644
index 0000000..a37270a
--- /dev/null
+++ b/packages/3-vite-plugin-tapi/package.json
@@ -0,0 +1,43 @@
+{
+ "name": "@farbenmeer/vite-plugin-tapi",
+ "version": "0.1.0",
+ "author": {
+ "name": "Michel Smola",
+ "email": "michel.smola@farbenmeer.de"
+ },
+ "type": "module",
+ "module": "dist/index.js",
+ "main": "dist/index.js",
+ "private": false,
+ "license": "MIT",
+ "files": [
+ "dist"
+ ],
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ }
+ },
+ "scripts": {
+ "build": "tsc --noEmit false",
+ "release": "pnpm build && pnpm publish --no-git-checks"
+ },
+ "dependencies": {
+ "srvx": "^0.11.15"
+ },
+ "peerDependencies": {
+ "@farbenmeer/tapi": "workspace:^",
+ "typescript": "^5 || ^6.0.0",
+ "vite": "^8.0.0"
+ },
+ "devDependencies": {
+ "@farbenmeer/tapi": "workspace:^",
+ "@types/node": "^25.0.3",
+ "vite": "^8.0.9"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/farbenmeer/tapi.git"
+ }
+}
diff --git a/packages/3-vite-plugin-tapi/src/index.ts b/packages/3-vite-plugin-tapi/src/index.ts
new file mode 100644
index 0000000..8eadb38
--- /dev/null
+++ b/packages/3-vite-plugin-tapi/src/index.ts
@@ -0,0 +1,164 @@
+import path from "node:path";
+import type { Plugin, ViteDevServer } from "vite";
+import { serve, type ServerHandler, type Server } from "srvx";
+import { createRequestHandler } from "@farbenmeer/tapi/server";
+
+export interface TapiPluginOptions {
+ /**
+ * Path to the file that exports `api` (a TApi `ApiDefinition`).
+ * Resolved against the Vite root. Default: `"src/api.ts"`.
+ */
+ entry?: string;
+ /**
+ * Base path passed to `createRequestHandler`. Default: `"/api"`.
+ */
+ basePath?: string;
+ /**
+ * Port for the dev server. Default: `PORT` env var or `3000`.
+ */
+ port?: number;
+ /**
+ * If `true`, the production build inlines `srvx` and `@farbenmeer/tapi`
+ * into the output so it runs without `node_modules`. Default: `false`.
+ */
+ standalone?: boolean;
+}
+
+const VIRTUAL_ID = "virtual:tapi-server";
+const RESOLVED_VIRTUAL_ID = "\0" + VIRTUAL_ID;
+
+export default function tapi(options: TapiPluginOptions = {}): Plugin {
+ const entryOption = options.entry ?? "src/api.ts";
+ const basePath = options.basePath ?? "/api";
+ const standalone = options.standalone ?? false;
+
+ let resolvedEntry = "";
+ let server: Server | undefined;
+ let currentHandler: ServerHandler | undefined;
+
+ return {
+ name: "vite-plugin-tapi",
+
+ config(_userConfig, env) {
+ if (env.command !== "build") return;
+ return {
+ build: {
+ ssr: true,
+ outDir: "dist",
+ rolldownOptions: {
+ input: VIRTUAL_ID,
+ output: {
+ format: "esm",
+ entryFileNames: "server.mjs",
+ },
+ ...(standalone
+ ? {}
+ : { external: ["srvx", /^@farbenmeer\/tapi(\/.*)?$/] }),
+ },
+ },
+ ssr: standalone ? { noExternal: true } : undefined,
+ };
+ },
+
+ configResolved(config) {
+ resolvedEntry = path.isAbsolute(entryOption)
+ ? entryOption
+ : path.resolve(config.root, entryOption);
+ },
+
+ resolveId(id) {
+ if (id === VIRTUAL_ID) return RESOLVED_VIRTUAL_ID;
+ return null;
+ },
+
+ load(id) {
+ if (id !== RESOLVED_VIRTUAL_ID) return null;
+ const entryImport = JSON.stringify(resolvedEntry);
+ return [
+ `import { serve } from "srvx";`,
+ `import { createRequestHandler } from "@farbenmeer/tapi/server";`,
+ `import { api } from ${entryImport};`,
+ ``,
+ `const fetch = createRequestHandler(api, { basePath: ${JSON.stringify(basePath)} });`,
+ `const port = Number(process.env.PORT) || ${options.port ?? 3000};`,
+ ``,
+ `const server = serve({ port, fetch });`,
+ `await server.ready();`,
+ `console.info(\`[tapi] server listening on \${server.url}\`);`,
+ ``,
+ ].join("\n");
+ },
+
+ async configureServer(vite) {
+ const loadHandler = async () => {
+ const mod = (await vite.ssrLoadModule(resolvedEntry)) as {
+ api?: Parameters[0];
+ };
+ if (!mod.api) {
+ throw new Error(
+ `[vite-plugin-tapi] ${resolvedEntry} must export \`api\` (an ApiDefinition).`,
+ );
+ }
+ currentHandler = createRequestHandler(mod.api, {
+ basePath,
+ hooks: {
+ error: (error) => {
+ if (error instanceof Error) vite.ssrFixStacktrace(error);
+ console.error(error);
+ },
+ },
+ });
+ };
+
+ try {
+ await loadHandler();
+ } catch (err) {
+ if (err instanceof Error) vite.ssrFixStacktrace(err);
+ console.error("[vite-plugin-tapi] initial load failed:", err);
+ }
+
+ const port = options.port ?? (Number(process.env.PORT) || 3000);
+ server = serve({
+ port,
+ fetch: async (req) => {
+ if (!currentHandler) {
+ return new Response("[vite-plugin-tapi] api not loaded", {
+ status: 503,
+ });
+ }
+ return currentHandler(req);
+ },
+ });
+ await server.ready();
+ console.info(`[vite-plugin-tapi] dev server on ${server.url}`);
+
+ const onChange = async () => {
+ vite.moduleGraph.invalidateAll();
+ try {
+ await loadHandler();
+ } catch (err) {
+ if (err instanceof Error) vite.ssrFixStacktrace(err);
+ console.error("[vite-plugin-tapi] reload failed:", err);
+ }
+ };
+ vite.watcher.on("change", onChange);
+ vite.watcher.on("add", onChange);
+ vite.watcher.on("unlink", onChange);
+
+ registerShutdown(vite);
+ },
+
+ async closeBundle() {
+ await server?.close();
+ server = undefined;
+ },
+ };
+
+ function registerShutdown(vite: ViteDevServer) {
+ const close = async () => {
+ await server?.close();
+ server = undefined;
+ };
+ vite.httpServer?.on("close", close);
+ }
+}
diff --git a/packages/3-vite-plugin-tapi/tsconfig.json b/packages/3-vite-plugin-tapi/tsconfig.json
new file mode 100644
index 0000000..3bfeeac
--- /dev/null
+++ b/packages/3-vite-plugin-tapi/tsconfig.json
@@ -0,0 +1,30 @@
+{
+ "compilerOptions": {
+ "lib": ["ESNext"],
+ "target": "ESNext",
+ "module": "Preserve",
+ "allowJs": true,
+
+ "moduleResolution": "bundler",
+ "verbatimModuleSyntax": true,
+ "noEmit": true,
+
+ "strict": true,
+ "skipLibCheck": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedIndexedAccess": true,
+ "noImplicitOverride": true,
+
+ "noUnusedLocals": false,
+ "noUnusedParameters": false,
+ "noPropertyAccessFromIndexSignature": false,
+
+ "rootDir": "./src",
+ "outDir": "./dist",
+ "declarationDir": "./dist",
+ "declaration": true,
+ "declarationMap": true
+ },
+ "include": ["src"],
+ "exclude": ["**/*.test.ts"]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 51cb65a..2c8d361 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -111,6 +111,34 @@ importers:
specifier: ^4
version: 4.2.2
+ examples/vite-plugin-tapi-demo:
+ dependencies:
+ '@farbenmeer/tapi':
+ specifier: workspace:^
+ version: link:../../packages/1-tapi
+ '@farbenmeer/vite-plugin-tapi':
+ specifier: workspace:^
+ version: link:../../packages/3-vite-plugin-tapi
+ srvx:
+ specifier: ^0.11.15
+ version: 0.11.15
+ typescript:
+ specifier: ^5 || ^6.0.0
+ version: 6.0.3
+ vite:
+ specifier: ^8.0.9
+ version: 8.0.9(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)
+ zod:
+ specifier: ^4.0.17
+ version: 4.3.6
+ devDependencies:
+ '@playwright/test':
+ specifier: ^1.58.1
+ version: 1.59.1
+ '@types/node':
+ specifier: ^25.0.3
+ version: 25.6.0
+
packages/1-lacy:
dependencies:
typescript:
@@ -406,6 +434,25 @@ importers:
specifier: ^4.0.16
version: 4.1.4(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(happy-dom@20.9.0)(vite@8.0.9(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0))
+ packages/3-vite-plugin-tapi:
+ dependencies:
+ srvx:
+ specifier: ^0.11.15
+ version: 0.11.15
+ typescript:
+ specifier: ^5 || ^6.0.0
+ version: 6.0.3
+ devDependencies:
+ '@farbenmeer/tapi':
+ specifier: workspace:^
+ version: link:../1-tapi
+ '@types/node':
+ specifier: ^25.0.3
+ version: 25.6.0
+ vite:
+ specifier: ^8.0.9
+ version: 8.0.9(@types/node@25.6.0)(esbuild@0.28.0)(jiti@2.6.1)(tsx@4.21.0)
+
packages/4-bunny-boilerplate:
dependencies:
'@farbenmeer/bunny':
@@ -4143,6 +4190,11 @@ packages:
resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==}
engines: {node: '>= 0.6'}
+ srvx@0.11.15:
+ resolution: {integrity: sha512-iXsux0UcOjdvs0LCMa2Ws3WwcDUozA3JN3BquNXkaFPP7TpRqgunKdEgoZ/uwb1J6xaYHfxtz9Twlh6yzwM6Tg==}
+ engines: {node: '>=20.16.0'}
+ hasBin: true
+
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
@@ -8655,6 +8707,8 @@ snapshots:
sqlstring@2.3.3: {}
+ srvx@0.11.15: {}
+
stackback@0.0.2: {}
statuses@1.5.0: {}