Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
3884c42
feat(fullstack): create package
hi-ogawa Sep 30, 2025
196f899
chore: lint
hi-ogawa Sep 30, 2025
0e8b71f
chore: readme
hi-ogawa Sep 30, 2025
bb03f6a
todo
hi-ogawa Sep 30, 2025
240adcc
plan
hi-ogawa Oct 1, 2025
dfb38d1
wip: example
hi-ogawa Oct 1, 2025
15b1106
chore: lint
hi-ogawa Oct 1, 2025
75db0f7
todo: client
hi-ogawa Oct 1, 2025
f2e28b2
chore: copy utils from `@vitejs/plugin-rsc`
hi-ogawa Oct 1, 2025
8866b1f
chore: comment
hi-ogawa Oct 1, 2025
ee1713f
wip: dev server css link
hi-ogawa Oct 1, 2025
853d594
chore: note on data-vite-dev-id
hi-ogawa Oct 1, 2025
cb3f27d
wip: support build
hi-ogawa Oct 1, 2025
e3dc89e
cleanup
hi-ogawa Oct 1, 2025
18e38b6
wip: test cloudflare plugin
hi-ogawa Oct 1, 2025
dadf3ac
chore: react hmr example
hi-ogawa Oct 2, 2025
8f83624
wip: handle data-vite-dev-id
hi-ogawa Oct 2, 2025
fd9d539
refactor: move plugins
hi-ogawa Oct 2, 2025
da1cf74
chore: comment
hi-ogawa Oct 2, 2025
efc53b2
ci: pkg.pr.new
hi-ogawa Oct 2, 2025
4db477d
chore: update examples
hi-ogawa Oct 2, 2025
f936091
chore: repro duplicate css
hi-ogawa Oct 2, 2025
1c99eaf
fix: deduplicate server link and client style on dev
hi-ogawa Oct 2, 2025
54ed43b
fix: handle css remove after hmr
hi-ogawa Oct 2, 2025
601e228
cleanup
hi-ogawa Oct 2, 2025
671e88b
wip: react router
hi-ogawa Oct 2, 2025
c93435e
wip: react router hydrate
hi-ogawa Oct 2, 2025
db61e94
wip: react rotuer route assets
hi-ogawa Oct 2, 2025
15d624f
fix: eagerly transform during collectCss
hi-ogawa Oct 2, 2025
a701d5b
refactor: move code
hi-ogawa Oct 2, 2025
6011bbc
chore: comment
hi-ogawa Oct 2, 2025
85768ed
wip
hi-ogawa Oct 2, 2025
3e26d38
wip: dynamic client entry
hi-ogawa Oct 2, 2025
953e135
chore: comment
hi-ogawa Oct 2, 2025
b2cb79b
refactor: react router example
hi-ogawa Oct 2, 2025
34db883
feat: export `mergeAssets` utils
hi-ogawa Oct 2, 2025
5e43327
refactor: use mergeAssets
hi-ogawa Oct 2, 2025
5368859
feat: improve React Router example demo app styling
hi-ogawa Oct 2, 2025
bb44d61
chore: simplify demo
hi-ogawa Oct 2, 2025
e3b1749
refactor: move code
hi-ogawa Oct 2, 2025
a243221
chore(fullstack): add vue-router example (#1171)
hi-ogawa Oct 2, 2025
f2b3b28
fix: fix package.json files
hi-ogawa Oct 2, 2025
2fc4555
chore: export assetsPlugin
hi-ogawa Oct 2, 2025
be6c839
cleanup
hi-ogawa Oct 2, 2025
acf5afb
fix: workaround vue scoped css hmr
hi-ogawa Oct 2, 2025
a580426
feat: add patchVueScopeCssHmr
hi-ogawa Oct 2, 2025
6eca907
chore: use bootstrap module
hi-ogawa Oct 2, 2025
4e3b891
chore: todo
hi-ogawa Oct 2, 2025
11ebd44
chore: rename
hi-ogawa Oct 2, 2025
53d745c
test: setup e2e
hi-ogawa Oct 2, 2025
4eb175d
chore: todo
hi-ogawa Oct 3, 2025
1a090fb
readme
hi-ogawa Oct 3, 2025
ed06d8a
readme
hi-ogawa Oct 3, 2025
898f0ff
ci: setup e2e
hi-ogawa Oct 3, 2025
29f2ffb
ci: fixup
hi-ogawa Oct 3, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@ jobs:
- run: pnpm tsc
- run: pnpm test

test-fullstack:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup
with:
build: false
- run: pnpm -C packages/fullstack build
- run: pnpm -C packages/fullstack test-e2e

# superseded by @vitejs/plugin-rsc
# https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-rsc
# test-rsc:
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/pkg-pr-new.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,5 @@ jobs:
packages/pre-bundle-new-url \
packages/server-asset \
packages/nitro \
packages/fullstack \
packages/ssr-css
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-server-dom-webpack": "^19.1.0",
"tinyexec": "^1.0.1",
"tsdown": "^0.12.9",
"typescript": "^5.8.3",
"vite": "^7.1.5",
Expand Down
138 changes: 138 additions & 0 deletions packages/fullstack/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
# @hiogawa/vite-plugin-fullstack

## SSR Assets API proposal

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 the API is to cover following use cases in SSR application:

- 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`](./examples/react-router) and [`examples/vue-router`](./examples/vue-router) for concrete integrations.

```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>
...
`;
...
}
```

The API is enabled by adding a plugin and minimal build configuration, for example:

```js
// [vite.config.ts]
import { defineConfig } from "vite"
import fullstack from "@hiogawa/vite-plugin-fullstack"

export default defineConfig({
plugins: [
fullstack({
// Ths plugin also provides server middleware using `export default { fetch }`
// of `ssr.build.rollupOptions.input` entry.
// This can be disabled by `serverHandler: false`
// in favor of `@cloudflare/vite-plugin`, `nitro/vite`, etc.
// > serverHandler: false,
})
],
environments: {
client: {
build: {
outDir: "./dist/client",
rollupOptions: {
input: {
index: "./src/entry.client.tsx",
},
},
},
},
ssr: {
build: {
outDir: "./dist/ssr",
rollupOptions: {
input: {
index: "./src/entry.server.tsx",
},
},
},
}
},
builder: {
async buildApp(builder) {
// currently the plugin relies on this build order
// to allow dynamically adding client entry
await builder.build(builder.environments["ssr"]!);
await builder.build(builder.environments["client"]!);
}
}
})
```

See [./examples](./examples) for concrete usages.

## Feedback

Feedback is appreciated! I'm especially curious about opinions from framework authors, who have likely implemented own solutions without such abstract API. For example,

- Is the API powerful enough?
- Is there anything to watch out when implementing this type of API?
78 changes: 78 additions & 0 deletions packages/fullstack/e2e/basic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { expect, test } from "@playwright/test";
import { type Fixture, useFixture } from "./fixture";
import { expectNoReload, waitForHydration } from "./helper";

test.describe("dev", () => {
const f = useFixture({ root: "examples/basic", mode: "dev" });
defineTest(f);
});

test.describe("build", () => {
const f = useFixture({ root: "examples/basic", mode: "build" });
defineTest(f);
});

function defineTest(f: Fixture) {
test("basic", async ({ page }) => {
await page.goto(f.url());
await using _ = await expectNoReload(page);

const errors: Error[] = [];
page.on("pageerror", (error) => {
errors.push(error);
});

// hydration
await waitForHydration(page, "main");
expect(errors).toEqual([]); // no hydration mismatch

// client
await expect(
page.getByRole("button", { name: "count is 0" }),
).toBeVisible();
await page.getByRole("button", { name: "count is 0" }).click();
await expect(
page.getByRole("button", { name: "count is 1" }),
).toBeVisible();

// css
await expect(page.locator(".read-the-docs")).toHaveCSS(
"color",
"rgb(136, 136, 136)",
);
expect(errors).toEqual([]);
});

// TODO
if (f.mode === "dev") {
test("hmr js", async ({ page }) => {
page;
});

test("hmr css", async ({ page }) => {
page;
});
}

test.describe(() => {
test.use({ javaScriptEnabled: false });

test("ssr", async ({ page }) => {
await page.goto(f.url());

// ssr
await expect(
page.getByRole("button", { name: "count is 0" }),
).toBeVisible();

// css
await expect(page.locator(".read-the-docs")).toHaveCSS(
"color",
"rgb(136, 136, 136)",
);

// modulepreload
// TODO
});
});
}
149 changes: 149 additions & 0 deletions packages/fullstack/e2e/fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import assert from "node:assert";
import { type SpawnOptions, spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { stripVTControlCharacters, styleText } from "node:util";
import test from "@playwright/test";
import { x } from "tinyexec";

function runCli(options: { command: string; label?: string } & SpawnOptions) {
const [name, ...args] = options.command.split(" ");
const child = x(name!, args, { nodeOptions: options }).process!;
const label = `[${options.label ?? "cli"}]`;
child.stdout!.on("data", (data) => {
if (process.env.TEST_DEBUG) {
console.log(styleText("cyan", label), data.toString());
}
});
child.stderr!.on("data", (data) => {
console.log(styleText("magenta", label), data.toString());
});
const done = new Promise<void>((resolve) => {
child.on("exit", (code) => {
if (code !== 0 && code !== 143 && process.platform !== "win32") {
console.log(styleText("magenta", `${label}`), `exit code ${code}`);
}
resolve();
});
});

async function findPort(): Promise<number> {
let stdout = "";
return new Promise((resolve) => {
child.stdout!.on("data", (data) => {
stdout += stripVTControlCharacters(String(data));
const match = stdout.match(
/http:\/\/(?:localhost|\[[::\d]+\]|[\d.]+):(\d+)/,
);
if (match) {
resolve(Number(match[1]));
}
});
});
}

function kill() {
if (process.platform === "win32") {
spawn("taskkill", ["/pid", String(child.pid), "/t", "/f"]);
} else {
child.kill();
}
}

return { proc: child, done, findPort, kill };
}

export type Fixture = ReturnType<typeof useFixture>;

export function useFixture(options: {
root: string;
mode?: "dev" | "build";
command?: string;
buildCommand?: string;
cliOptions?: SpawnOptions;
}) {
let cleanup: (() => Promise<void>) | undefined;
let baseURL!: string;

const cwd = path.resolve(options.root);

// TODO: `beforeAll` is called again on any test failure.
// https://playwright.dev/docs/test-retries
test.beforeAll(async () => {
if (options.mode === "dev") {
const proc = runCli({
command: options.command ?? `pnpm dev`,
label: `${options.root}:dev`,
cwd,
...options.cliOptions,
});
const port = await proc.findPort();
// TODO: use `test.extend` to set `baseURL`?
baseURL = `http://localhost:${port}`;
cleanup = async () => {
proc.kill();
await proc.done;
};
}
if (options.mode === "build") {
if (!process.env.TEST_SKIP_BUILD) {
const proc = runCli({
command: options.buildCommand ?? `pnpm build`,
label: `${options.root}:build`,
cwd,
...options.cliOptions,
});
await proc.done;
}
const proc = runCli({
command: options.command ?? `pnpm preview`,
label: `${options.root}:preview`,
cwd,
...options.cliOptions,
});
const port = await proc.findPort();
baseURL = `http://localhost:${port}`;
cleanup = async () => {
proc.kill();
await proc.done;
};
}
});

test.afterAll(async () => {
await cleanup?.();
});

const originalFiles: Record<string, string> = {};

function createEditor(filepath: string) {
filepath = path.resolve(cwd, filepath);
const init = fs.readFileSync(filepath, "utf-8");
originalFiles[filepath] ??= init;
let current = init;
return {
edit(editFn: (data: string) => string): void {
const next = editFn(current);
assert(next !== current, "Edit function did not change the content");
current = next;
fs.writeFileSync(filepath, next);
},
reset(): void {
fs.writeFileSync(filepath, originalFiles[filepath]!);
},
};
}

test.afterAll(async () => {
for (const [filepath, content] of Object.entries(originalFiles)) {
fs.writeFileSync(filepath, content);
}
});

return {
mode: options.mode,
root: cwd,
url: (url: string = "./") => new URL(url, baseURL).href,
createEditor,
};
}
Loading
Loading