@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 +125 -0
- package/dist/plugin.d.ts +21 -0
- package/dist/plugin.js +157 -0
- package/package.json +46 -0
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
|
package/dist/plugin.d.ts
ADDED
|
@@ -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
|
+
}
|