@toapi/vite-plugin 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,209 @@
1
+ # @toapi/vite-plugin
2
+
3
+ Vite plugin for [Toapi](https://github.com/farbenmeer/tapi). Bundles your Toapi
4
+ API alongside your Vite frontend in a single project: serves the API as
5
+ middleware in dev and preview mode, and produces a deployable server bundle
6
+ for production.
7
+
8
+ ## Installation
9
+
10
+ ```bash
11
+ pnpm add -D @toapi/vite-plugin
12
+ pnpm add @toapi/server srvx
13
+ ```
14
+
15
+ Peer-deps: `vite ^8`, `@toapi/server`.
16
+
17
+ ## Usage
18
+
19
+ `vite.config.ts`:
20
+ ```ts
21
+ import { defineConfig } from "vite";
22
+ import toapi from "@toapi/vite-plugin";
23
+
24
+ export default defineConfig({
25
+ plugins: [toapi()],
26
+ });
27
+ ```
28
+
29
+ The plugin expects `src/api.ts` to export `api` (an `ApiDefinition`):
30
+ ```ts
31
+ import { defineApi, defineHandler, TResponse } from "@toapi/server";
32
+
33
+ export const api = defineApi().route("/hello", {
34
+ GET: defineHandler({ authorize: () => true }, async () => {
35
+ return TResponse.json({ message: "hello" });
36
+ }),
37
+ });
38
+ ```
39
+
40
+ In dev (`vite`) and preview (`vite preview`) modes, the plugin attaches a
41
+ middleware to Vite's server that handles requests at the configured
42
+ `basePath` (default `/api`). The same Vite server serves both the frontend
43
+ and the API on a single port.
44
+
45
+ ## Options
46
+
47
+ | Option | Type | Default | Description |
48
+ |---|---|---|---|
49
+ | `entry` | `string` | `"src/api.ts"` | Path to the file exporting `api`. Resolved against the Vite root. |
50
+ | `basePath` | `string` | `"/api"` | Prefix for API routes. Use `""` to mount at the root. |
51
+ | `port` | `number` | — | Default port for Vite's dev/preview server. Falls back to the `PORT` env var. |
52
+ | `external` | `(string \| RegExp)[]` | `[]` | Packages to keep external in the server bundle. By default everything is bundled. |
53
+
54
+ ## Environment variables
55
+
56
+ ### Dev (`pnpm dev`)
57
+
58
+ The plugin loads `.env`, `.env.local`, `.env.<mode>`, and `.env.<mode>.local`
59
+ via Vite's `loadEnv` and mirrors every key into `process.env` before the api
60
+ module is loaded. Existing `process.env` values keep precedence, so shell
61
+ vars override `.env` files.
62
+
63
+ ```
64
+ .env # all envs
65
+ .env.local # all envs, ignored by git
66
+ .env.development # dev only
67
+ .env.development.local # dev only, ignored by git
68
+ ```
69
+
70
+ This covers server-side libraries that read `process.env.X` directly
71
+ (BetterAuth, DB clients, OAuth secrets). Vite's `import.meta.env` injection
72
+ still applies to client code.
73
+
74
+ ### Production (`srvx dist/server/server.js`)
75
+
76
+ The built server does **not** load `.env` files. In production, env vars come
77
+ from the runtime — Docker, systemd, or your PaaS.
78
+
79
+
80
+ ## Build output
81
+
82
+ `vite build` writes two clearly separated trees:
83
+
84
+ ```
85
+ dist/
86
+ ├── client/ — static frontend assets (HTML, JS, CSS, images)
87
+ ├── server.js — bundled server (sourcemap included)
88
+ └── server.js.map
89
+ ```
90
+
91
+ The split exists for safety: server-only code (database credentials,
92
+ third-party API keys, server-side libraries) lives in `dist/server.js` and
93
+ **must not** be deployed to a public static host. Treating `dist/client/` as
94
+ the static-deploy root makes it impossible to leak the server bundle by
95
+ accident.
96
+
97
+ ## Service worker
98
+
99
+ To get tapi's offline / tag-based revalidation behavior, add a service
100
+ worker built by [`vite-plugin-pwa`](https://vite-pwa-org.netlify.app/) in
101
+ `injectManifest` mode. The two plugins compose cleanly: `tapi()` redirects
102
+ the client build to `dist/client/`, which is exactly where VitePWA emits
103
+ `sw.js`, and the production server bundle is built separately so
104
+ nothing leaks across.
105
+
106
+ ```bash
107
+ pnpm add -D vite-plugin-pwa
108
+ ```
109
+
110
+ ```ts
111
+ // vite.config.ts
112
+ import { defineConfig } from "vite";
113
+ import tapi from "@toapi/vite-plugin";
114
+ import { VitePWA } from "vite-plugin-pwa";
115
+
116
+ export default defineConfig({
117
+ plugins: [
118
+ tapi(),
119
+ VitePWA({
120
+ strategies: "injectManifest",
121
+ srcDir: "src",
122
+ filename: "service-worker.ts",
123
+ injectRegister: "auto",
124
+ devOptions: { enabled: true, type: "module" },
125
+ // optional, pass-through to VitePWA:
126
+ // manifest: { name: "My App", short_name: "App", ... },
127
+ }),
128
+ ],
129
+ });
130
+ ```
131
+
132
+ ```ts
133
+ // src/service-worker.ts
134
+ import {
135
+ handleTapiRequest,
136
+ listenForInvalidations,
137
+ cleanup,
138
+ } from "@toapi/worker";
139
+
140
+ declare const self: ServiceWorkerGlobalScope;
141
+
142
+ self.addEventListener("activate", (event) => {
143
+ // Drop cache entries that have been expired longer than 7 days,
144
+ // remove orphans, and rebuild the tags index.
145
+ event.waitUntil(cleanup({ maximumStaleAge: 60 * 60 * 24 * 7 }));
146
+ });
147
+
148
+ self.addEventListener("fetch", (event) => {
149
+ const url = new URL(event.request.url);
150
+ if (
151
+ url.pathname.startsWith("/api") &&
152
+ !url.pathname.startsWith("/api/__tapi")
153
+ ) {
154
+ event.respondWith(handleTapiRequest(event.request));
155
+ }
156
+ });
157
+
158
+ listenForInvalidations({ url: "/api/__tapi/invalidations" });
159
+ ```
160
+
161
+ Notes:
162
+
163
+ - Add `"WebWorker"` to your `tsconfig.json` `lib` array so TypeScript
164
+ recognizes `ServiceWorkerGlobalScope` and friends.
165
+ - `devOptions.enabled: true` makes the SW run during `vite dev` too;
166
+ otherwise it only runs in `vite preview` and production.
167
+ - Adjust the `/api` checks in the SW to match the `basePath` you pass to
168
+ `tapi()`.
169
+ - `cleanup`'s `maximumStaleAge` is the grace period (in seconds) past a
170
+ cache entry's `expiresAt` before it's actually deleted on next SW
171
+ activation.
172
+
173
+ ## Deployment
174
+
175
+ The server bundle is a fetch-handler module. Serve it in production with the
176
+ [srvx](https://srvx.h3.dev) CLI:
177
+
178
+ ```bash
179
+ srvx --prod dist/server.js
180
+ ```
181
+
182
+ `srvx` picks up `PORT`, `HOST`, and other settings from environment variables.
183
+
184
+ ### Static assets
185
+
186
+ Static frontend assets in `dist/client/` should ideally be served by a
187
+ dedicated static host — nginx, Caddy, or an S3-compatible bucket fronted by a
188
+ CDN. Dedicated static hosts give you better caching, compression, HTTP/2,
189
+ and offload the load from your Node.js server.
190
+
191
+ If you don't want a separate static host, srvx can serve them too with the
192
+ `-s` flag:
193
+
194
+ ```bash
195
+ srvx --prod -s client dist/server.js
196
+ ```
197
+
198
+ The path passed to `-s` is resolved **relative to the directory containing
199
+ the entry file** (`dist/`), so `client` points at `dist/client/`.
200
+ API routes are matched first; static files fall through when no API route
201
+ handles the request.
202
+
203
+ > **Note:** `srvx -s` serves files as-is. It has **no SPA history fallback**
204
+ > (deep-linking or reloading a client-side route returns `404`) and sets **no
205
+ > `Cache-Control`/`ETag`** — content-hashed assets are not marked `immutable`
206
+ > and there is no `304` revalidation. For a client-routed SPA in production,
207
+ > front it with a static server or CDN that handles the history fallback and
208
+ > caching. See the reference `Caddyfile` in
209
+ > [`examples/vite-plugin-tapi-demo`](https://github.com/farbenmeer/tapi/tree/main/examples/vite-plugin-tapi-demo).
@@ -0,0 +1,2 @@
1
+ export type ApiHandler = (request: Request) => Promise<Response> | Response;
2
+ //# sourceMappingURL=ApiHandler.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ApiHandler.d.ts","sourceRoot":"","sources":["../src/ApiHandler.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,UAAU,GAAG,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,GAAG,QAAQ,CAAC"}
File without changes
@@ -0,0 +1,4 @@
1
+ import type { Connect } from "vite";
2
+ import type { ApiHandler } from "./ApiHandler.js";
3
+ export declare function createApiMiddleware(getHandler: () => ApiHandler | undefined, basePath: string): Connect.NextHandleFunction;
4
+ //# sourceMappingURL=createApiMiddleware.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"createApiMiddleware.d.ts","sourceRoot":"","sources":["../src/createApiMiddleware.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAC;AAEpC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAElD,wBAAgB,mBAAmB,CACjC,UAAU,EAAE,MAAM,UAAU,GAAG,SAAS,EACxC,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,kBAAkB,CAsB5B"}
@@ -0,0 +1,24 @@
1
+ import { NodeRequest, sendNodeResponse } from "srvx/node";
2
+ export function createApiMiddleware(getHandler, basePath) {
3
+ return async (req, res, next) => {
4
+ try {
5
+ const handler = getHandler();
6
+ if (!handler)
7
+ return next();
8
+ const url = req.url ?? "/";
9
+ if (basePath && !url.startsWith(basePath))
10
+ return next();
11
+ const webReq = new NodeRequest({ req, res });
12
+ const webRes = await handler(webReq);
13
+ // When there's no basePath restriction, fall through to Vite on 404
14
+ // so static files, HMR, etc. are still served correctly.
15
+ if (webRes.status === 404 && !basePath)
16
+ return next();
17
+ await sendNodeResponse(res, webRes);
18
+ }
19
+ catch (error) {
20
+ console.error(`[vite-plugin-tapi]`, error);
21
+ next();
22
+ }
23
+ };
24
+ }
@@ -0,0 +1,25 @@
1
+ import type { Plugin } from "vite";
2
+ export interface TapiPluginOptions {
3
+ /**
4
+ * Path to the file that exports `api` (a TApi `ApiDefinition`).
5
+ * Resolved against the Vite root. Default: `"src/api.ts"`.
6
+ */
7
+ entry?: string;
8
+ /**
9
+ * Base path passed to `createRequestHandler`. Default: `"/api"`.
10
+ */
11
+ basePath?: string;
12
+ /**
13
+ * Default port for Vite's dev/preview server. Falls back to the `PORT`
14
+ * environment variable when set.
15
+ */
16
+ port?: number;
17
+ /**
18
+ * Packages to keep external in the server bundle.
19
+ * Passed directly to `rolldownOptions.external`.
20
+ * Default: `[]` — everything is bundled.
21
+ */
22
+ external?: (string | RegExp)[];
23
+ }
24
+ export default function tapi(options?: TapiPluginOptions): Plugin;
25
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAC;AAKnC,MAAM,WAAW,iBAAiB;IAChC;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;OAEG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,QAAQ,CAAC,EAAE,CAAC,MAAM,GAAG,MAAM,CAAC,EAAE,CAAC;CAChC;AAED,MAAM,CAAC,OAAO,UAAU,IAAI,CAAC,OAAO,GAAE,iBAAsB,GAAG,MAAM,CA4LpE"}
package/dist/index.js ADDED
@@ -0,0 +1,163 @@
1
+ import { createRequestHandler } from "@toapi/server";
2
+ import path from "node:path";
3
+ import { loadEnv } from "vite";
4
+ import { createApiMiddleware } from "./createApiMiddleware.js";
5
+ export default function tapi(options = {}) {
6
+ const entryOption = options.entry ?? "src/api.ts";
7
+ const basePath = options.basePath ?? "/api";
8
+ let userOutDirBase = "dist";
9
+ let resolvedEntry = "";
10
+ let resolvedRoot = "";
11
+ let serverOutDir = "";
12
+ let isBuildCommand = false;
13
+ let currentHandler;
14
+ return {
15
+ name: "vite-plugin-tapi",
16
+ config(userConfig, _env) {
17
+ // Capture the user's outDir BEFORE we override it, so we can compute
18
+ // the parallel server output directory in later hooks.
19
+ userOutDirBase = userConfig.build?.outDir ?? "dist";
20
+ const port = options.port ??
21
+ (process.env.PORT ? Number(process.env.PORT) : undefined);
22
+ // Always redirect the client build to <outDir>/client so server code
23
+ // (written by closeBundle into <outDir>/server) cannot leak into the
24
+ // static hosting deploy.
25
+ return {
26
+ build: { outDir: path.join(userOutDirBase, "client") },
27
+ ...(port ? { server: { port }, preview: { port } } : {}),
28
+ };
29
+ },
30
+ configResolved(config) {
31
+ resolvedEntry = path.isAbsolute(entryOption)
32
+ ? entryOption
33
+ : path.resolve(config.root, entryOption);
34
+ resolvedRoot = config.root;
35
+ serverOutDir = path.resolve(config.root, userOutDirBase);
36
+ isBuildCommand = config.command === "build";
37
+ },
38
+ async configureServer(vite) {
39
+ // Vite injects .env values into `import.meta.env` for the client only;
40
+ // the api runs in this same Node process and reads `process.env`. Mirror
41
+ // every var (empty prefix, not just VITE_*) into process.env so server
42
+ // code sees secrets like BETTER_AUTH_SECRET. Existing process.env wins.
43
+ const env = loadEnv(vite.config.mode, vite.config.envDir, "");
44
+ for (const [k, v] of Object.entries(env)) {
45
+ if (process.env[k] === undefined)
46
+ process.env[k] = v;
47
+ }
48
+ const loadHandler = async () => {
49
+ const mod = (await vite.ssrLoadModule(resolvedEntry));
50
+ if (!mod.api) {
51
+ throw new Error(`[vite-plugin-tapi] ${resolvedEntry} must export \`api\` (an ApiDefinition).`);
52
+ }
53
+ if (!mod.api.logger) {
54
+ mod.api.logger = {
55
+ error: (error) => {
56
+ if (error instanceof Error)
57
+ vite.ssrFixStacktrace(error);
58
+ console.error(error);
59
+ },
60
+ };
61
+ }
62
+ currentHandler = createRequestHandler(mod.api, {
63
+ basePath,
64
+ });
65
+ };
66
+ try {
67
+ await loadHandler();
68
+ }
69
+ catch (err) {
70
+ if (err instanceof Error)
71
+ vite.ssrFixStacktrace(err);
72
+ console.error("[vite-plugin-tapi] initial load failed:", err);
73
+ }
74
+ vite.middlewares.use(createApiMiddleware(() => currentHandler, basePath));
75
+ const onChange = async () => {
76
+ vite.moduleGraph.invalidateAll();
77
+ try {
78
+ await loadHandler();
79
+ }
80
+ catch (err) {
81
+ if (err instanceof Error)
82
+ vite.ssrFixStacktrace(err);
83
+ console.error("[vite-plugin-tapi] reload failed:", err);
84
+ }
85
+ };
86
+ vite.watcher.on("change", onChange);
87
+ vite.watcher.on("add", onChange);
88
+ vite.watcher.on("unlink", onChange);
89
+ },
90
+ async configurePreviewServer(previewServer) {
91
+ // Same rationale as configureServer: mirror .env values into process.env
92
+ // so the bundled server (running in this same Node process) can read
93
+ // secrets via process.env. Existing process.env wins.
94
+ const env = loadEnv(previewServer.config.mode, previewServer.config.envDir, "");
95
+ for (const [k, v] of Object.entries(env)) {
96
+ if (process.env[k] === undefined)
97
+ process.env[k] = v;
98
+ }
99
+ const serverJsPath = path.join(path.resolve(previewServer.config.root, userOutDirBase), "server.js");
100
+ let fetchHandler;
101
+ try {
102
+ const mod = (await import(serverJsPath));
103
+ fetchHandler = mod.default?.fetch ?? mod.fetch;
104
+ if (!fetchHandler) {
105
+ console.warn("[vite-plugin-tapi] dist/server/server.js has no fetch export — run `vite build` first.");
106
+ }
107
+ }
108
+ catch (err) {
109
+ console.error("[vite-plugin-tapi] failed to import dist/server/server.js for preview:", err);
110
+ }
111
+ if (!fetchHandler)
112
+ return;
113
+ previewServer.middlewares.use(createApiMiddleware(() => fetchHandler, basePath));
114
+ },
115
+ async closeBundle() {
116
+ if (!isBuildCommand)
117
+ return;
118
+ const VIRTUAL_SERVER_ID = "virtual:tapi-server";
119
+ const RESOLVED_VIRTUAL_SERVER_ID = "\0" + VIRTUAL_SERVER_ID;
120
+ const inlinePlugin = () => ({
121
+ name: "tapi-server-virtual",
122
+ resolveId(id) {
123
+ return id === VIRTUAL_SERVER_ID ? RESOLVED_VIRTUAL_SERVER_ID : null;
124
+ },
125
+ load(id) {
126
+ if (id !== RESOLVED_VIRTUAL_SERVER_ID)
127
+ return null;
128
+ return [
129
+ `import { createRequestHandler } from "@toapi/server";`,
130
+ `import { api } from ${JSON.stringify(resolvedEntry)};`,
131
+ ``,
132
+ `export const fetch = createRequestHandler(api, { basePath: ${JSON.stringify(basePath)} });`,
133
+ `export default { fetch };`,
134
+ ].join("\n");
135
+ },
136
+ });
137
+ const { build } = await import("vite");
138
+ await build({
139
+ root: resolvedRoot,
140
+ configFile: false,
141
+ logLevel: "silent",
142
+ plugins: [inlinePlugin()],
143
+ // noExternal: true so node_modules are bundled by default;
144
+ // only packages in options.external are kept external.
145
+ ssr: { noExternal: true },
146
+ build: {
147
+ ssr: true,
148
+ outDir: serverOutDir,
149
+ emptyOutDir: false,
150
+ sourcemap: true,
151
+ rolldownOptions: {
152
+ input: { server: VIRTUAL_SERVER_ID },
153
+ output: {
154
+ format: "esm",
155
+ entryFileNames: "[name].js",
156
+ },
157
+ external: options.external ?? [],
158
+ },
159
+ },
160
+ });
161
+ },
162
+ };
163
+ }
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@toapi/vite-plugin",
3
+ "version": "0.3.0",
4
+ "author": {
5
+ "name": "Michel Smola",
6
+ "email": "michel.smola@farbenmeer.de"
7
+ },
8
+ "type": "module",
9
+ "module": "dist/index.js",
10
+ "main": "dist/index.js",
11
+ "private": false,
12
+ "license": "MIT",
13
+ "files": [
14
+ "dist"
15
+ ],
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "default": "./dist/index.js"
20
+ }
21
+ },
22
+ "dependencies": {
23
+ "srvx": "^0.11.15"
24
+ },
25
+ "peerDependencies": {
26
+ "typescript": "^5 || ^6.0.0",
27
+ "vite": "^8.0.0",
28
+ "@toapi/server": "^0.12.1"
29
+ },
30
+ "devDependencies": {
31
+ "@types/node": "^25.0.3",
32
+ "vite": "^8.0.9",
33
+ "@toapi/server": "^0.12.1"
34
+ },
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/farbenmeer/tapi.git"
38
+ },
39
+ "scripts": {
40
+ "build": "tsc --noEmit false",
41
+ "release": "pnpm build && pnpm publish --no-git-checks"
42
+ }
43
+ }