Skip to content

Commit c3ba75a

Browse files
authored
feat(fullstack): prototype import.meta.vite.assets API (#1168)
## SSR Assets API This is a proposal to introduce a new API to allow non-client environment to access assets information commonly required for SSR. Currently, it is prototyped in my package `@hiogawa/vite-plugin-fullstack` and it provides `import.meta.vite.assets` function with a following signature: ```ts function assets({ import?: string, environment?: string }): { entry?: string; // script for <script type="module" src=...> js: { href: string, ... }[]; // dependency chunks for <link rel="modulepreload" href=... /> css: { href: string, ... }[]; // dependency css for <link rel="stylesheet" href=... /> }; ``` The goal of this API is to cover following use cases: - for server entry to access client entry ```js // [server.js] server entry injecting client entry during SSR function renderHtml() { const assets = import.meta.vite.assets({ entry: "./client.js", environment: "client", }); const head = ` <script type="module" src=${JSON.stringify(assets.entry)}></script> <link type="modulepreload" href=${JSON.stringify(assets.js[0].href)}></script> ... `; ... } ``` - for universal route to access assets within its route - see `examples/react-rotuer` and `examples/vue-router` for concrete examples ```js // [routes.js] hypothetical router library's routes declaration export const routes = [ { path: "/about" route: () => import("./pages/about.js"), routeAssets: mergeAssets( import.meta.vite.assets({ entry: "./pages/about.js", environment: "client", }), import.meta.vite.assets({ entry: "./pages/about.js", environment: "ssr", }), ) }, ... ] ``` - server only app to access css ```js // [server.js] import "./styles.css" // this will be included in `assets.css` below function renderHtml() { const assets = import.meta.vite.assets({ // both `import` and `environment` is optional and they are default to current module and environment // import: "./server.js", // environment: "ssr", }); const head = ` <link type="stylesheet" href=${JSON.stringify(assets.css[0].href)}></script> ... `; ... } ``` ## TODO ### MVP - [ ] mvp API design + implementation - [x] client entry on server - [x] client assets / dependencies on server - [x] server css on server - [ ] examples - [x] `create-vite-extra`-like ssr example https://github.com/bluwy/create-vite-extra/ - [x] `@cloudflare/vite-plugin` - [ ] `nitro/vite` examples https://github.com/nitrojs/vite-examples - [x] island - [x] client only - [ ] ssr - [ ] fresh - [ ] router library - [x] react router - [x] vue router - scoped css hmr is broken :( because of `&lang.css` vs `&lang.css=` difference? it looks like new css link is returned as `text/javascript`? workarounded by forcing `&lang.css` via middleware. - [ ] e2e - [ ] docs - [ ] RFC on vite discussion ### Future - API - add client entry dynamically - `transformIndexHtml` on server - handle server change event (e.g. reload / refetch) - deduplicate client and server css on build when css code split differs - treat css on server like client reference? - create custom integration of router libraries - react router - vue router - test it on ecosystem framework - `fresh` (server css)
1 parent bd990e8 commit c3ba75a

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

77 files changed

+3432
-117
lines changed

.github/workflows/ci.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,16 @@ jobs:
2525
- run: pnpm tsc
2626
- run: pnpm test
2727

28+
test-fullstack:
29+
runs-on: ubuntu-latest
30+
steps:
31+
- uses: actions/checkout@v4
32+
- uses: ./.github/actions/setup
33+
with:
34+
build: false
35+
- run: pnpm -C packages/fullstack build
36+
- run: pnpm -C packages/fullstack test-e2e
37+
2838
# superseded by @vitejs/plugin-rsc
2939
# https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-rsc
3040
# test-rsc:

.github/workflows/pkg-pr-new.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,5 @@ jobs:
5252
packages/pre-bundle-new-url \
5353
packages/server-asset \
5454
packages/nitro \
55+
packages/fullstack \
5556
packages/ssr-css

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"react": "^19.1.0",
3030
"react-dom": "^19.1.0",
3131
"react-server-dom-webpack": "^19.1.0",
32+
"tinyexec": "^1.0.1",
3233
"tsdown": "^0.12.9",
3334
"typescript": "^5.8.3",
3435
"vite": "^7.1.5",

packages/fullstack/README.md

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
# @hiogawa/vite-plugin-fullstack
2+
3+
## SSR Assets API proposal
4+
5+
This is a proposal to introduce a new API to allow non-client environment to access assets information commonly required for SSR.
6+
7+
Currently, it is prototyped in my package `@hiogawa/vite-plugin-fullstack` and it provides `import.meta.vite.assets` function with a following signature:
8+
9+
```ts
10+
function assets({ import?: string, environment?: string }): {
11+
entry?: string; // script for <script type="module" src=...>
12+
js: { href: string, ... }[]; // dependency chunks for <link rel="modulepreload" href=... />
13+
css: { href: string, ... }[]; // dependency css for <link rel="stylesheet" href=... />
14+
};
15+
```
16+
17+
The goal of the API is to cover following use cases in SSR application:
18+
19+
- for server entry to access client entry
20+
21+
```js
22+
// [server.js] server entry injecting client entry during SSR
23+
function renderHtml() {
24+
const assets = import.meta.vite.assets({
25+
entry: "./client.js",
26+
environment: "client",
27+
});
28+
const head = `
29+
<script type="module" src=${JSON.stringify(assets.entry)}></script>
30+
<link type="modulepreload" href=${JSON.stringify(assets.js[0].href)}></script>
31+
...
32+
`;
33+
...
34+
}
35+
```
36+
37+
- for universal route to access assets within its route
38+
- see [`examples/react-rotuer`](./examples/react-router) and [`examples/vue-router`](./examples/vue-router) for concrete integrations.
39+
40+
```js
41+
// [routes.js] hypothetical router library's routes declaration
42+
export const routes = [
43+
{
44+
path: "/about"
45+
route: () => import("./pages/about.js"),
46+
routeAssets: mergeAssets(
47+
import.meta.vite.assets({
48+
entry: "./pages/about.js",
49+
environment: "client",
50+
}),
51+
import.meta.vite.assets({
52+
entry: "./pages/about.js",
53+
environment: "ssr",
54+
}),
55+
)
56+
},
57+
...
58+
]
59+
```
60+
61+
- server only app to access css
62+
63+
```js
64+
// [server.js]
65+
import "./styles.css" // this will be included in `assets.css` below
66+
67+
function renderHtml() {
68+
const assets = import.meta.vite.assets({
69+
// both `import` and `environment` is optional and they are default to current module and environment
70+
// import: "./server.js",
71+
// environment: "ssr",
72+
});
73+
const head = `
74+
<link type="stylesheet" href=${JSON.stringify(assets.css[0].href)}></script>
75+
...
76+
`;
77+
...
78+
}
79+
```
80+
81+
The API is enabled by adding a plugin and minimal build configuration, for example:
82+
83+
```js
84+
// [vite.config.ts]
85+
import { defineConfig } from "vite"
86+
import fullstack from "@hiogawa/vite-plugin-fullstack"
87+
88+
export default defineConfig({
89+
plugins: [
90+
fullstack({
91+
// Ths plugin also provides server middleware using `export default { fetch }`
92+
// of `ssr.build.rollupOptions.input` entry.
93+
// This can be disabled by `serverHandler: false`
94+
// in favor of `@cloudflare/vite-plugin`, `nitro/vite`, etc.
95+
// > serverHandler: false,
96+
})
97+
],
98+
environments: {
99+
client: {
100+
build: {
101+
outDir: "./dist/client",
102+
rollupOptions: {
103+
input: {
104+
index: "./src/entry.client.tsx",
105+
},
106+
},
107+
},
108+
},
109+
ssr: {
110+
build: {
111+
outDir: "./dist/ssr",
112+
rollupOptions: {
113+
input: {
114+
index: "./src/entry.server.tsx",
115+
},
116+
},
117+
},
118+
}
119+
},
120+
builder: {
121+
async buildApp(builder) {
122+
// currently the plugin relies on this build order
123+
// to allow dynamically adding client entry
124+
await builder.build(builder.environments["ssr"]!);
125+
await builder.build(builder.environments["client"]!);
126+
}
127+
}
128+
})
129+
```
130+
131+
See [./examples](./examples) for concrete usages.
132+
133+
## Feedback
134+
135+
Feedback is appreciated! I'm especially curious about opinions from framework authors, who have likely implemented own solutions without such abstract API. For example,
136+
137+
- Is the API powerful enough?
138+
- Is there anything to watch out when implementing this type of API?
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { expect, test } from "@playwright/test";
2+
import { type Fixture, useFixture } from "./fixture";
3+
import { expectNoReload, waitForHydration } from "./helper";
4+
5+
test.describe("dev", () => {
6+
const f = useFixture({ root: "examples/basic", mode: "dev" });
7+
defineTest(f);
8+
});
9+
10+
test.describe("build", () => {
11+
const f = useFixture({ root: "examples/basic", mode: "build" });
12+
defineTest(f);
13+
});
14+
15+
function defineTest(f: Fixture) {
16+
test("basic", async ({ page }) => {
17+
await page.goto(f.url());
18+
await using _ = await expectNoReload(page);
19+
20+
const errors: Error[] = [];
21+
page.on("pageerror", (error) => {
22+
errors.push(error);
23+
});
24+
25+
// hydration
26+
await waitForHydration(page, "main");
27+
expect(errors).toEqual([]); // no hydration mismatch
28+
29+
// client
30+
await expect(
31+
page.getByRole("button", { name: "count is 0" }),
32+
).toBeVisible();
33+
await page.getByRole("button", { name: "count is 0" }).click();
34+
await expect(
35+
page.getByRole("button", { name: "count is 1" }),
36+
).toBeVisible();
37+
38+
// css
39+
await expect(page.locator(".read-the-docs")).toHaveCSS(
40+
"color",
41+
"rgb(136, 136, 136)",
42+
);
43+
expect(errors).toEqual([]);
44+
});
45+
46+
// TODO
47+
if (f.mode === "dev") {
48+
test("hmr js", async ({ page }) => {
49+
page;
50+
});
51+
52+
test("hmr css", async ({ page }) => {
53+
page;
54+
});
55+
}
56+
57+
test.describe(() => {
58+
test.use({ javaScriptEnabled: false });
59+
60+
test("ssr", async ({ page }) => {
61+
await page.goto(f.url());
62+
63+
// ssr
64+
await expect(
65+
page.getByRole("button", { name: "count is 0" }),
66+
).toBeVisible();
67+
68+
// css
69+
await expect(page.locator(".read-the-docs")).toHaveCSS(
70+
"color",
71+
"rgb(136, 136, 136)",
72+
);
73+
74+
// modulepreload
75+
// TODO
76+
});
77+
});
78+
}

packages/fullstack/e2e/fixture.ts

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import assert from "node:assert";
2+
import { type SpawnOptions, spawn } from "node:child_process";
3+
import fs from "node:fs";
4+
import path from "node:path";
5+
import { stripVTControlCharacters, styleText } from "node:util";
6+
import test from "@playwright/test";
7+
import { x } from "tinyexec";
8+
9+
function runCli(options: { command: string; label?: string } & SpawnOptions) {
10+
const [name, ...args] = options.command.split(" ");
11+
const child = x(name!, args, { nodeOptions: options }).process!;
12+
const label = `[${options.label ?? "cli"}]`;
13+
child.stdout!.on("data", (data) => {
14+
if (process.env.TEST_DEBUG) {
15+
console.log(styleText("cyan", label), data.toString());
16+
}
17+
});
18+
child.stderr!.on("data", (data) => {
19+
console.log(styleText("magenta", label), data.toString());
20+
});
21+
const done = new Promise<void>((resolve) => {
22+
child.on("exit", (code) => {
23+
if (code !== 0 && code !== 143 && process.platform !== "win32") {
24+
console.log(styleText("magenta", `${label}`), `exit code ${code}`);
25+
}
26+
resolve();
27+
});
28+
});
29+
30+
async function findPort(): Promise<number> {
31+
let stdout = "";
32+
return new Promise((resolve) => {
33+
child.stdout!.on("data", (data) => {
34+
stdout += stripVTControlCharacters(String(data));
35+
const match = stdout.match(
36+
/http:\/\/(?:localhost|\[[::\d]+\]|[\d.]+):(\d+)/,
37+
);
38+
if (match) {
39+
resolve(Number(match[1]));
40+
}
41+
});
42+
});
43+
}
44+
45+
function kill() {
46+
if (process.platform === "win32") {
47+
spawn("taskkill", ["/pid", String(child.pid), "/t", "/f"]);
48+
} else {
49+
child.kill();
50+
}
51+
}
52+
53+
return { proc: child, done, findPort, kill };
54+
}
55+
56+
export type Fixture = ReturnType<typeof useFixture>;
57+
58+
export function useFixture(options: {
59+
root: string;
60+
mode?: "dev" | "build";
61+
command?: string;
62+
buildCommand?: string;
63+
cliOptions?: SpawnOptions;
64+
}) {
65+
let cleanup: (() => Promise<void>) | undefined;
66+
let baseURL!: string;
67+
68+
const cwd = path.resolve(options.root);
69+
70+
// TODO: `beforeAll` is called again on any test failure.
71+
// https://playwright.dev/docs/test-retries
72+
test.beforeAll(async () => {
73+
if (options.mode === "dev") {
74+
const proc = runCli({
75+
command: options.command ?? `pnpm dev`,
76+
label: `${options.root}:dev`,
77+
cwd,
78+
...options.cliOptions,
79+
});
80+
const port = await proc.findPort();
81+
// TODO: use `test.extend` to set `baseURL`?
82+
baseURL = `http://localhost:${port}`;
83+
cleanup = async () => {
84+
proc.kill();
85+
await proc.done;
86+
};
87+
}
88+
if (options.mode === "build") {
89+
if (!process.env.TEST_SKIP_BUILD) {
90+
const proc = runCli({
91+
command: options.buildCommand ?? `pnpm build`,
92+
label: `${options.root}:build`,
93+
cwd,
94+
...options.cliOptions,
95+
});
96+
await proc.done;
97+
}
98+
const proc = runCli({
99+
command: options.command ?? `pnpm preview`,
100+
label: `${options.root}:preview`,
101+
cwd,
102+
...options.cliOptions,
103+
});
104+
const port = await proc.findPort();
105+
baseURL = `http://localhost:${port}`;
106+
cleanup = async () => {
107+
proc.kill();
108+
await proc.done;
109+
};
110+
}
111+
});
112+
113+
test.afterAll(async () => {
114+
await cleanup?.();
115+
});
116+
117+
const originalFiles: Record<string, string> = {};
118+
119+
function createEditor(filepath: string) {
120+
filepath = path.resolve(cwd, filepath);
121+
const init = fs.readFileSync(filepath, "utf-8");
122+
originalFiles[filepath] ??= init;
123+
let current = init;
124+
return {
125+
edit(editFn: (data: string) => string): void {
126+
const next = editFn(current);
127+
assert(next !== current, "Edit function did not change the content");
128+
current = next;
129+
fs.writeFileSync(filepath, next);
130+
},
131+
reset(): void {
132+
fs.writeFileSync(filepath, originalFiles[filepath]!);
133+
},
134+
};
135+
}
136+
137+
test.afterAll(async () => {
138+
for (const [filepath, content] of Object.entries(originalFiles)) {
139+
fs.writeFileSync(filepath, content);
140+
}
141+
});
142+
143+
return {
144+
mode: options.mode,
145+
root: cwd,
146+
url: (url: string = "./") => new URL(url, baseURL).href,
147+
createEditor,
148+
};
149+
}

0 commit comments

Comments
 (0)