diff --git a/packages/docs/public/sitemap.xml b/packages/docs/public/sitemap.xml
deleted file mode 100644
index 3ff7374..0000000
--- a/packages/docs/public/sitemap.xml
+++ /dev/null
@@ -1,48 +0,0 @@
-
-
-
- https://static.funstack.work/
-
-
- https://static.funstack.work/getting-started
-
-
- https://static.funstack.work/getting-started/migrating-from-vite-spa
-
-
- https://static.funstack.work/faq
-
-
- https://static.funstack.work/api/funstack-static
-
-
- https://static.funstack.work/api/defer
-
-
- https://static.funstack.work/api/entry-definition
-
-
- https://static.funstack.work/learn/how-it-works
-
-
- https://static.funstack.work/learn/rsc
-
-
- https://static.funstack.work/learn/optimizing-payloads
-
-
- https://static.funstack.work/learn/lazy-server-components
-
-
- https://static.funstack.work/learn/defer-and-activity
-
-
- https://static.funstack.work/learn/file-system-routing
-
-
- https://static.funstack.work/advanced/multiple-entrypoints
-
-
- https://static.funstack.work/advanced/ssr
-
-
diff --git a/packages/docs/src/build.ts b/packages/docs/src/build.ts
new file mode 100644
index 0000000..620adb5
--- /dev/null
+++ b/packages/docs/src/build.ts
@@ -0,0 +1,43 @@
+import { writeFile } from "node:fs/promises";
+import path from "node:path";
+import type { BuildEntryFunction } from "@funstack/static/server";
+import type { RouteDefinition } from "@funstack/router/server";
+import { routes } from "./App";
+
+const siteUrl = "https://static.funstack.work";
+
+function collectPaths(routes: RouteDefinition[]): string[] {
+ const paths: string[] = [];
+ for (const route of routes) {
+ if (route.children) {
+ paths.push(...collectPaths(route.children));
+ } else if (route.path !== undefined && route.path !== "*") {
+ paths.push(route.path);
+ }
+ }
+ return paths;
+}
+
+function generateSitemap(paths: string[]): string {
+ const urls = paths
+ .map((p) => {
+ const loc = p === "/" ? siteUrl + "/" : `${siteUrl}${p}`;
+ return ` \n ${loc}\n `;
+ })
+ .join("\n");
+
+ return `
+
+${urls}
+
+`;
+}
+
+export default (async ({ build, outDir }) => {
+ const paths = collectPaths(routes);
+
+ await Promise.all([
+ build(),
+ writeFile(path.join(outDir, "sitemap.xml"), generateSitemap(paths)),
+ ]);
+}) satisfies BuildEntryFunction;
diff --git a/packages/docs/vite.config.ts b/packages/docs/vite.config.ts
index 0cc5aed..258cdbd 100644
--- a/packages/docs/vite.config.ts
+++ b/packages/docs/vite.config.ts
@@ -14,6 +14,7 @@ export default defineConfig(async () => {
funstackStatic({
entries: "./src/entries.tsx",
ssr: true,
+ build: "./src/build.ts",
}),
{
// to make .mdx loading lazy
diff --git a/packages/static/src/build/buildApp.ts b/packages/static/src/build/buildApp.ts
index 35b6a5d..aadee83 100644
--- a/packages/static/src/build/buildApp.ts
+++ b/packages/static/src/build/buildApp.ts
@@ -25,55 +25,63 @@ export async function buildApp(
const baseDir = config.environments.client.build.outDir;
const base = normalizeBase(config.base);
- const { entries, deferRegistry } = await entry.build();
-
- // Validate all entry paths
- const paths: string[] = [];
- for (const result of entries) {
- const error = validateEntryPath(result.path);
- if (error) {
- throw new Error(error);
+ async function doBuild() {
+ const { entries, deferRegistry } = await entry.build();
+
+ // Validate all entry paths
+ const paths: string[] = [];
+ for (const result of entries) {
+ const error = validateEntryPath(result.path);
+ if (error) {
+ throw new Error(error);
+ }
+ paths.push(result.path);
+ }
+ const dupError = checkDuplicatePaths(paths);
+ if (dupError) {
+ throw new Error(dupError);
}
- paths.push(result.path);
- }
- const dupError = checkDuplicatePaths(paths);
- if (dupError) {
- throw new Error(dupError);
- }
-
- // Process all deferred components once across all entries.
- // We pass a dummy empty stream since we handle per-entry RSC payloads separately.
- const dummyStream = new ReadableStream({
- start(controller) {
- controller.close();
- },
- });
- const { components, idMapping } = await processRscComponents(
- deferRegistry.loadAll(),
- dummyStream,
- options.rscPayloadDir,
- context,
- );
- // Write each entry's HTML and RSC payload
- for (const result of entries) {
- await buildSingleEntry(
- result,
- idMapping,
- baseDir,
- base,
+ // Process all deferred components once across all entries.
+ // We pass a dummy empty stream since we handle per-entry RSC payloads separately.
+ const dummyStream = new ReadableStream({
+ start(controller) {
+ controller.close();
+ },
+ });
+ const { components, idMapping } = await processRscComponents(
+ deferRegistry.loadAll(),
+ dummyStream,
options.rscPayloadDir,
context,
);
+
+ // Write each entry's HTML and RSC payload
+ for (const result of entries) {
+ await buildSingleEntry(
+ result,
+ idMapping,
+ baseDir,
+ base,
+ options.rscPayloadDir,
+ context,
+ );
+ }
+
+ // Write all deferred component payloads
+ for (const { finalId, finalContent, name } of components) {
+ const filePath = path.join(
+ baseDir,
+ getModulePathFor(finalId).replace(/^\//, ""),
+ );
+ await writeFileNormal(filePath, finalContent, context, name);
+ }
}
- // Write all deferred component payloads
- for (const { finalId, finalContent, name } of components) {
- const filePath = path.join(
- baseDir,
- getModulePathFor(finalId).replace(/^\//, ""),
- );
- await writeFileNormal(filePath, finalContent, context, name);
+ if (entry.buildEntry) {
+ await entry.buildEntry({ build: doBuild, outDir: baseDir });
+ } else {
+ await doBuild();
}
}
diff --git a/packages/static/src/buildEntryDefinition.ts b/packages/static/src/buildEntryDefinition.ts
new file mode 100644
index 0000000..5f0f9b2
--- /dev/null
+++ b/packages/static/src/buildEntryDefinition.ts
@@ -0,0 +1,23 @@
+/**
+ * Context passed to the build entry function.
+ */
+export interface BuildEntryContext {
+ /**
+ * Performs the default build flow (rendering entries and writing output files).
+ * Call this to execute the standard build process.
+ * You can run additional work before, after, or in parallel with this function.
+ */
+ build: () => Promise;
+ /**
+ * Absolute path to the output directory where built files are written.
+ * Use this to write additional files (e.g. sitemap.xml) alongside the build output.
+ */
+ outDir: string;
+}
+
+/**
+ * The build entry module should default-export a function with this signature.
+ */
+export type BuildEntryFunction = (
+ context: BuildEntryContext,
+) => Promise | void;
diff --git a/packages/static/src/entries/server.ts b/packages/static/src/entries/server.ts
index d8d1256..93c83f2 100644
--- a/packages/static/src/entries/server.ts
+++ b/packages/static/src/entries/server.ts
@@ -1 +1,5 @@
export { defer, type DeferOptions } from "../rsc/defer";
+export type {
+ BuildEntryContext,
+ BuildEntryFunction,
+} from "../buildEntryDefinition";
diff --git a/packages/static/src/plugin/index.ts b/packages/static/src/plugin/index.ts
index b5b7d29..acd1f1d 100644
--- a/packages/static/src/plugin/index.ts
+++ b/packages/static/src/plugin/index.ts
@@ -40,6 +40,19 @@ interface FunstackStaticBaseOptions {
* @default "fun__rsc-payload"
*/
rscPayloadDir?: string;
+ /**
+ * Path to a module that customizes the build process.
+ * The module should `export default` an async function that receives
+ * `{ build }` where `build` is a function that performs the default
+ * build flow.
+ *
+ * This allows you to run additional work before/after the build,
+ * or to control the build execution (e.g. parallel work).
+ * Only called during production builds, not in dev mode.
+ *
+ * The module runs in the RSC environment.
+ */
+ build?: string;
}
interface SingleEntryOptions {
@@ -95,6 +108,7 @@ export default function funstackStatic(
let resolvedEntriesModule: string = "__uninitialized__";
let resolvedClientInitEntry: string | undefined;
+ let resolvedBuildEntry: string | undefined;
// Determine whether user specified entries or root+app
const isMultiEntry = "entries" in options && options.entries !== undefined;
@@ -152,6 +166,11 @@ export default function funstackStatic(
path.resolve(config.root, clientInit),
);
}
+ if (options.build) {
+ resolvedBuildEntry = normalizePath(
+ path.resolve(config.root, options.build),
+ );
+ }
},
configEnvironment(_name, config) {
if (!config.optimizeDeps) {
@@ -189,6 +208,9 @@ export default function funstackStatic(
if (id === "virtual:funstack/client-init") {
return "\0virtual:funstack/client-init";
}
+ if (id === "virtual:funstack/build-entry") {
+ return "\0virtual:funstack/build-entry";
+ }
},
load(id) {
if (id === "\0virtual:funstack/entries") {
@@ -218,6 +240,12 @@ export default function funstackStatic(
}
return "";
}
+ if (id === "\0virtual:funstack/build-entry") {
+ if (resolvedBuildEntry) {
+ return `export { default } from "${resolvedBuildEntry}";`;
+ }
+ return "export default undefined;";
+ }
},
},
{
diff --git a/packages/static/src/rsc/entry.tsx b/packages/static/src/rsc/entry.tsx
index 678e873..8bf07c3 100644
--- a/packages/static/src/rsc/entry.tsx
+++ b/packages/static/src/rsc/entry.tsx
@@ -316,6 +316,7 @@ export async function build() {
}
export { defer } from "./defer";
+export { default as buildEntry } from "virtual:funstack/build-entry";
if (import.meta.hot) {
import.meta.hot.accept();
diff --git a/packages/static/src/virtual.d.ts b/packages/static/src/virtual.d.ts
index 0f79119..95e5118 100644
--- a/packages/static/src/virtual.d.ts
+++ b/packages/static/src/virtual.d.ts
@@ -8,3 +8,8 @@ declare module "virtual:funstack/config" {
export const rscPayloadDir: string;
}
declare module "virtual:funstack/client-init" {}
+declare module "virtual:funstack/build-entry" {
+ import type { BuildEntryFunction } from "./buildEntryDefinition";
+ const buildEntry: BuildEntryFunction | undefined;
+ export default buildEntry;
+}