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: {}