Skip to content
62 changes: 47 additions & 15 deletions packages/void/src/prerender-paths.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,50 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import { createServer } from "vite";
import { createServer, loadConfigFromFile, type Plugin } from "vite";
import { scanPages } from "./pages/scan";
import { detectSsrPrerender, getSsrServerEntryPath } from "./ssr/entry";
import type { VoidConfig } from "./config";

/**
* Create a temporary Vite dev server for ssrLoadModule().
*
* Loads the user's full Vite config (plugins, resolve aliases, SSR options,
* etc.) but filters out @cloudflare/vite-plugin plugins which replace the
* SSR environment with a FetchableDevEnvironment that requires a running
* Miniflare backend, making ssrLoadModule() unusable.
*/
async function createSsrLoadServer(root: string) {
const loaded = await loadConfigFromFile(
{
command: "serve",
mode: process.env.NODE_ENV === "development" ? "development" : "production",
},
undefined,
root,
);

if (!loaded) {
throw new Error("[void] Failed to load Vite config — expected a vite.config.{ts,js,mjs} file");
}

const { plugins: rawPlugins, ...restConfig } = loaded.config;
const plugins = ((rawPlugins ?? []) as unknown[]).flat(Infinity).filter((p): p is Plugin => {
if (!p || typeof p !== "object") return false;
const name = (p as Plugin).name;
if (!name) return true;
return !name.startsWith("vite-plugin-cloudflare");
});

return createServer({
...restConfig,
root,
configFile: false,
server: { middlewareMode: true },
logLevel: "silent",
plugins,
});
}

/**
* Collect all paths that should be prerendered.
*
Expand Down Expand Up @@ -39,12 +79,7 @@ export async function collectPrerenderPaths(options: {
(p) => p.prerender && p.hasGetPrerenderPaths && (p.params.length > 0 || p.catchAll),
);
if (dynamicPrerenderPages.length > 0) {
const viteServer = await createServer({
root,
configFile: false,
server: { middlewareMode: true },
logLevel: "silent",
});
const viteServer = await createSsrLoadServer(root);
try {
for (const page of dynamicPrerenderPages) {
try {
Expand All @@ -65,8 +100,9 @@ export async function collectPrerenderPaths(options: {
}
}
} catch (err) {
console.warn(
throw new Error(
`[void] Failed to resolve prerender paths for ${page.pattern}: ${err instanceof Error ? err.message : String(err)}`,
{ cause: err },
);
}
}
Expand All @@ -79,12 +115,7 @@ export async function collectPrerenderPaths(options: {
// Custom SSR: getPrerenderPaths() in src/main.ssr.ts
const ssrPrerender = detectSsrPrerender(root);
if (ssrPrerender?.hasGetPrerenderPaths) {
const viteServer = await createServer({
root,
configFile: false,
server: { middlewareMode: true },
logLevel: "silent",
});
const viteServer = await createSsrLoadServer(root);
try {
const entryPath = getSsrServerEntryPath(root)!;
const mod = await viteServer.ssrLoadModule(entryPath);
Expand All @@ -97,8 +128,9 @@ export async function collectPrerenderPaths(options: {
}
}
} catch (err) {
console.warn(
throw new Error(
`[void] Failed to resolve SSR prerender paths: ${err instanceof Error ? err.message : String(err)}`,
{ cause: err },
);
} finally {
await viteServer.close();
Expand Down
12 changes: 12 additions & 0 deletions packages/void/test/integration/static-pages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,18 @@ describe("playground/static-pages build (output: static)", () => {
expect(html).toContain("Post hello");
});

it("prerenders dynamic pages with getPrerenderPaths using virtual modules", () => {
// tags/[tag] exports getPrerenderPaths that imports from virtual:tags plugin
const vuePath = join(DIST, "client/tags/vue.html");
const vitePath = join(DIST, "client/tags/vite.html");
expect(existsSync(vuePath)).toBe(true);
expect(existsSync(vitePath)).toBe(true);

const html = readFileSync(vuePath, "utf-8");
expect(html).toContain('data-testid="tag-title"');
expect(html).toContain("Tag vue");
});

it("does not prerender dynamic pages without getPrerenderPaths", () => {
// items/[id] has no getPrerenderPaths, so it should NOT be prerendered
expect(existsSync(join(DIST, "client/items"))).toBe(false);
Expand Down
16 changes: 16 additions & 0 deletions playground/static-pages/pages/tags/[tag].server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { defineHandler } from "void";
import { tags } from "virtual:tags";

export interface Props {
tag: string;
}

export const prerender = true;

export function getPrerenderPaths() {
return tags.map((tag: string) => ({ tag }));
}

export const loader = defineHandler<Props>((c) => {
return { tag: c.req.param("tag") };
});
9 changes: 9 additions & 0 deletions playground/static-pages/pages/tags/[tag].vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<script setup lang="ts">
defineProps<{ tag: string }>();
</script>

<template>
<div>
<h1 data-testid="tag-title">Tag {{ tag }}</h1>
</div>
</template>
20 changes: 18 additions & 2 deletions playground/static-pages/vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,23 @@
import { defineConfig } from "vite";
import { defineConfig, type Plugin } from "vite";
import { voidPlugin } from "void";
import { voidVue } from "@void-x/vue";

function virtualTagsPlugin(): Plugin {
const VIRTUAL_ID = "virtual:tags";
const RESOLVED_ID = "\0virtual:tags";
return {
name: "virtual-tags",
resolveId(id) {
if (id === VIRTUAL_ID) return RESOLVED_ID;
},
load(id) {
if (id === RESOLVED_ID) {
return `export const tags = ["vue", "vite"];`;
}
},
};
}

export default defineConfig({
plugins: [voidPlugin(), voidVue()],
plugins: [voidPlugin(), voidVue(), virtualTagsPlugin()],
});