@havelaer/vite-plugin-ssr 0.0.3

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,125 @@
1
+ # Vite SSR
2
+
3
+ SSR for Vite. And optional API servers. Build with Vite's new [Environment API](https://vite.dev/guide/api-environment.html).
4
+
5
+ ## Getting Started
6
+
7
+ Install the SSR Vite plugin.
8
+
9
+ ```bash
10
+ npm install @havelaer/vite-plugin-ssr
11
+ ```
12
+
13
+ Configure the plugin in your Vite config by providing the client entry, the SSR entry, and optionally the API entries.
14
+
15
+ The keys in the `apis` object are the names of the APIs. They are also used as base path for the API requests. Eg. `/api*` requests will be sent to the `api` API.
16
+
17
+ ```ts
18
+ // vite.config.ts
19
+ import { defineConfig } from "vite";
20
+ import ssr from "@havelaer/vite-plugin-ssr";
21
+
22
+ export default defineConfig({
23
+ plugins: [ssr({
24
+ client: "src/entry-client.ts",
25
+ ssr: "src/entry-ssr.ts",
26
+ apis: {
27
+ api: "src/entry-api.ts",
28
+ },
29
+ })],
30
+ });
31
+ ```
32
+
33
+ Setup your client entry.
34
+
35
+ ```ts
36
+ // src/entry-client.ts
37
+ console.log("Hello from client");
38
+
39
+ fetch("/api").then((res) => res.json()).then((data) => {
40
+ console.log(data.message); // "Hello from the API"
41
+ });
42
+ ```
43
+
44
+ Setup your SSR entry. This serves the HTML based on the request.
45
+
46
+ ```ts
47
+ // src/entry-ssr.ts
48
+ import clientEntryUrl from "./entry-client.ts?url";
49
+
50
+ export default function fetch(request: Request): Promise<Response> {
51
+ return new Response(`
52
+ <h1>Hello from server</h1>
53
+ <script src="${clientEntryUrl}" type="module"></script>
54
+ `);
55
+ }
56
+ ```
57
+
58
+ Optionally, setup your API entry.
59
+
60
+ ```ts
61
+ // src/entry-api.ts
62
+ export default function fetch(request: Request): Promise<Response> {
63
+ return new Response(JSON.stringify({
64
+ message: "Hello from the API",
65
+ }), {
66
+ headers: {
67
+ "Content-Type": "application/json",
68
+ },
69
+ });
70
+ }
71
+ ```
72
+
73
+ ## Production
74
+
75
+ First update your package.json to build all environments by adding the `--app` flag to the `vite build` script.
76
+ Also add a `serve` script to run the server.
77
+
78
+ ```json
79
+ {
80
+ "scripts": {
81
+ "dev": "vite dev",
82
+ "build": "vite build --app",
83
+ "serve": "node server.js"
84
+ }
85
+ }
86
+ ```
87
+
88
+ Setup a server. You can use any server and any runtime. For this example we're using [Hono](https://hono.dev) with the Node.js runtime.
89
+
90
+ ```js
91
+ // server.js
92
+ import { serve } from "@hono/node-server";
93
+ import { serveStatic } from "@hono/node-server/serve-static";
94
+ import { Hono } from "hono";
95
+ import apiFetch from "./dist/api/entry-api.js";
96
+ import ssrFetch from "./dist/ssr/entry-ssr.js";
97
+
98
+ const app = new Hono();
99
+
100
+ app.use(serveStatic({ root: "./dist/client" }));
101
+
102
+ app.use("/api/*", (c) => apiFetch(c.req.raw));
103
+
104
+ app.use((c) => ssrFetch(c.req.raw));
105
+
106
+ serve(app, (info) => {
107
+ console.log(`Listening on http://localhost:${info.port}`);
108
+ });
109
+ ```
110
+
111
+ Build for production.
112
+
113
+ ```bash
114
+ npm run build
115
+ ```
116
+
117
+ Run the server.
118
+
119
+ ```bash
120
+ npm run serve
121
+ ```
122
+
123
+ ## License
124
+
125
+ MIT
@@ -0,0 +1,21 @@
1
+ import { EnvironmentOptions, Plugin } from "vite";
2
+
3
+ //#region src/plugin.d.ts
4
+ type ServerEntryHandler = (req: Request) => Promise<Response>;
5
+ interface BaseEnvConfig {
6
+ entry: string;
7
+ environment?: (env: EnvironmentOptions) => EnvironmentOptions;
8
+ }
9
+ interface ClientConfig extends BaseEnvConfig {}
10
+ interface SSRConfig extends BaseEnvConfig {}
11
+ interface APIConfig extends BaseEnvConfig {
12
+ route?: string;
13
+ }
14
+ type Options = {
15
+ client: string | ClientConfig;
16
+ ssr: string | SSRConfig;
17
+ apis?: Record<string, string | APIConfig>;
18
+ };
19
+ declare function ssrPlugin(options: Options): Plugin;
20
+ //#endregion
21
+ export { ServerEntryHandler, ssrPlugin as default };
package/dist/plugin.js ADDED
@@ -0,0 +1,157 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { getRequestListener } from "@hono/node-server";
4
+ import * as cheerio from "cheerio";
5
+ import { createServerModuleRunner, normalizePath } from "vite";
6
+
7
+ //#region src/plugin.ts
8
+ function getEntry(config) {
9
+ if (typeof config === "string") return config;
10
+ return config.entry;
11
+ }
12
+ function getEnvironment(config, environment) {
13
+ if (typeof config === "string") return environment;
14
+ return config.environment?.(environment) ?? environment;
15
+ }
16
+ function extractHtmlScripts(html) {
17
+ const $ = cheerio.load(html);
18
+ const scripts = [];
19
+ $("script").each((_, element) => {
20
+ const src = $(element).attr("src");
21
+ const content = $(element).html() ?? void 0;
22
+ scripts.push({
23
+ src,
24
+ content
25
+ });
26
+ });
27
+ return scripts;
28
+ }
29
+ function ssrPlugin(options) {
30
+ let viteServer;
31
+ let resolvedConfig;
32
+ let configEnv;
33
+ let injectedScripts = [];
34
+ return {
35
+ name: "havelaer-vite-ssr",
36
+ sharedDuringBuild: true,
37
+ enforce: "pre",
38
+ config(config, env) {
39
+ configEnv = env;
40
+ const outDirRoot = config.build?.outDir ?? "dist";
41
+ return {
42
+ environments: {
43
+ client: getEnvironment(options.client, { build: {
44
+ outDir: `${outDirRoot}/client`,
45
+ emitAssets: true,
46
+ copyPublicDir: true,
47
+ emptyOutDir: false,
48
+ rollupOptions: {
49
+ input: normalizePath(path.resolve(getEntry(options.client))),
50
+ output: {
51
+ entryFileNames: "static/entry-client.js",
52
+ chunkFileNames: "static/assets/[name]-[hash].js",
53
+ assetFileNames: "static/assets/[name]-[hash][extname]"
54
+ }
55
+ }
56
+ } }),
57
+ ssr: getEnvironment(options.ssr, { build: {
58
+ outDir: `${outDirRoot}/ssr`,
59
+ copyPublicDir: false,
60
+ emptyOutDir: false,
61
+ ssrEmitAssets: false,
62
+ rollupOptions: {
63
+ input: normalizePath(path.resolve(getEntry(options.ssr))),
64
+ output: {
65
+ entryFileNames: "entry-ssr.js",
66
+ chunkFileNames: "assets/[name]-[hash].js",
67
+ assetFileNames: "assets/[name]-[hash][extname]"
68
+ }
69
+ }
70
+ } }),
71
+ ...options.apis ? Object.entries(options.apis).reduce((apiEnvironments, [api, config$1]) => {
72
+ apiEnvironments[api] = getEnvironment(config$1, { build: {
73
+ rollupOptions: {
74
+ input: normalizePath(path.resolve(getEntry(config$1))),
75
+ output: { entryFileNames: `entry-${api}.js` }
76
+ },
77
+ outDir: `${outDirRoot}/${api}`,
78
+ emptyOutDir: false,
79
+ copyPublicDir: false
80
+ } });
81
+ return apiEnvironments;
82
+ }, {}) : {}
83
+ },
84
+ builder: { async buildApp(builder) {
85
+ await fs.rm(path.resolve(builder.config.root, outDirRoot), {
86
+ recursive: true,
87
+ force: true
88
+ });
89
+ await Promise.all([
90
+ builder.build(builder.environments.client),
91
+ builder.build(builder.environments.ssr),
92
+ ...options.apis ? Object.entries(options.apis).map(([api]) => builder.build(builder.environments[api])) : []
93
+ ]);
94
+ } },
95
+ appType: "custom"
96
+ };
97
+ },
98
+ configResolved(config) {
99
+ resolvedConfig = config;
100
+ },
101
+ async configureServer(server) {
102
+ viteServer = server;
103
+ const ssrRunner = createServerModuleRunner(server.environments.ssr);
104
+ const templateHtml = `<html><head></head><body></body></html>`;
105
+ const transformedHtml = await server.transformIndexHtml("/", templateHtml);
106
+ injectedScripts = extractHtmlScripts(transformedHtml);
107
+ if (options.apis) Object.entries(options.apis).forEach(([api, config]) => {
108
+ const moduleRunner = createServerModuleRunner(server.environments[api]);
109
+ const route = typeof config !== "string" && config.route ? config.route : `/${api}`;
110
+ server.middlewares.use(async (req, res, next) => {
111
+ if (req.url?.startsWith(route)) {
112
+ const apiFetch = await moduleRunner.import(getEntry(config));
113
+ await getRequestListener(apiFetch.default)(req, res);
114
+ return;
115
+ }
116
+ next();
117
+ });
118
+ });
119
+ return async () => {
120
+ server.middlewares.use(async (req, res, next) => {
121
+ if (res.writableEnded) return next();
122
+ try {
123
+ const ssrFetch = await ssrRunner.import(getEntry(options.ssr));
124
+ await getRequestListener(ssrFetch.default)(req, res);
125
+ } catch (e) {
126
+ viteServer?.ssrFixStacktrace(e);
127
+ console.info(e.stack);
128
+ res.statusCode = 500;
129
+ res.end(e.stack);
130
+ }
131
+ });
132
+ };
133
+ },
134
+ hotUpdate(ctx) {
135
+ if (this.environment.name === "ssr" && ctx.modules.length > 0) ctx.server.environments.client.hot.send({ type: "full-reload" });
136
+ },
137
+ resolveId(id, parent) {
138
+ if (id.endsWith("?url") && parent) {
139
+ const resolvedClientEntry = normalizePath(path.resolve(getEntry(options.client)));
140
+ const resolvedId = path.resolve(path.dirname(parent), id.slice(0, -4));
141
+ if (resolvedId === resolvedClientEntry) return `\0virtual:vite-ssr/client-entry-url`;
142
+ }
143
+ if (id.endsWith("@vite-ssr-entry-client")) return `\0virtual:vite-ssr/client-entry`;
144
+ },
145
+ load(id) {
146
+ if (id === `\0virtual:vite-ssr/client-entry-url`) if (configEnv.command === "build") return `export default "${resolvedConfig.base}static/entry-client.js";`;
147
+ else return `export default "${resolvedConfig.base}@vite-ssr-entry-client";`;
148
+ if (id === `\0virtual:vite-ssr/client-entry`) {
149
+ const content = injectedScripts.map((script) => script.content || `import "${script.src}";`).join("\n");
150
+ return `${content}\nawait import("${resolvedConfig.base}${getEntry(options.client)}");`;
151
+ }
152
+ }
153
+ };
154
+ }
155
+
156
+ //#endregion
157
+ export { ssrPlugin as default };
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@havelaer/vite-plugin-ssr",
3
+ "version": "0.0.3",
4
+ "description": "SSR for Vite",
5
+ "type": "module",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "exports": {
10
+ ".": {
11
+ "import": "./dist/plugin.js",
12
+ "types": "./dist/plugin.d.ts"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "bin"
18
+ ],
19
+ "scripts": {
20
+ "build": "tsdown src/plugin.ts --dts",
21
+ "dev": "tsdown src/plugin.ts --dts --watch",
22
+ "lint": "biome check .",
23
+ "prepublishOnly": "npm run lint -- --fix && npm run build && npm version patch -m 'chore: publishing version %s'",
24
+ "test": "echo \"Error: no test specified\" && exit 1"
25
+ },
26
+ "keywords": [
27
+ "vite",
28
+ "ssr"
29
+ ],
30
+ "author": "Havelaer",
31
+ "license": "MIT",
32
+ "peerDependencies": {
33
+ "vite": "^6.0.0 || ^7.0.0"
34
+ },
35
+ "devDependencies": {
36
+ "@biomejs/biome": "^2.1.3",
37
+ "@types/node": "^24.1.0",
38
+ "tsdown": "^0.13.1",
39
+ "typescript": "^5.9.2",
40
+ "vite": "^7.0.6"
41
+ },
42
+ "dependencies": {
43
+ "@hono/node-server": "^1.18.0",
44
+ "cheerio": "^1.1.2"
45
+ }
46
+ }