From 20f7df53399496f5c38634f70b8894bd7e6eb0e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 14:09:42 +0000 Subject: [PATCH 1/3] feat: add configurable build entry point Add a `build` option to the plugin that accepts a path to a module that can customize the build process. The module default-exports a function receiving `{ build }`, allowing users to run additional work (e.g. sitemap generation) before, after, or in parallel with the standard build flow. The module runs in the RSC environment and is only invoked during production builds. https://claude.ai/code/session_01D7p4ghgU7hqJA6SnMfVtPA --- packages/static/package.json | 4 + packages/static/src/build/buildApp.ts | 92 +++++++++++---------- packages/static/src/buildEntryDefinition.ts | 18 ++++ packages/static/src/plugin/index.ts | 28 +++++++ packages/static/src/rsc/entry.tsx | 1 + packages/static/src/virtual.d.ts | 5 ++ packages/static/tsdown.config.ts | 1 + 7 files changed, 107 insertions(+), 42 deletions(-) create mode 100644 packages/static/src/buildEntryDefinition.ts diff --git a/packages/static/package.json b/packages/static/package.json index 1807be6..3bd8a46 100644 --- a/packages/static/package.json +++ b/packages/static/package.json @@ -27,6 +27,10 @@ "types": "./dist/entryDefinition.d.mts", "import": "./dist/entryDefinition.mjs" }, + "./build-entry": { + "types": "./dist/buildEntryDefinition.d.mts", + "import": "./dist/buildEntryDefinition.mjs" + }, "./server": { "types": "./dist/entries/server.d.mts", "import": "./dist/entries/server.mjs" diff --git a/packages/static/src/build/buildApp.ts b/packages/static/src/build/buildApp.ts index 35b6a5d..5c8c61c 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 }); + } else { + await doBuild(); } } diff --git a/packages/static/src/buildEntryDefinition.ts b/packages/static/src/buildEntryDefinition.ts new file mode 100644 index 0000000..a3a0e24 --- /dev/null +++ b/packages/static/src/buildEntryDefinition.ts @@ -0,0 +1,18 @@ +/** + * 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; +} + +/** + * 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/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; +} diff --git a/packages/static/tsdown.config.ts b/packages/static/tsdown.config.ts index 8eb5a9d..062653a 100644 --- a/packages/static/tsdown.config.ts +++ b/packages/static/tsdown.config.ts @@ -4,6 +4,7 @@ export default defineConfig({ entry: [ "src/index.ts", "src/entryDefinition.ts", + "src/buildEntryDefinition.ts", "src/entries/*.ts", "src/bin/*.ts", ], From 117fe486f6ca63c135183026a30e87ed250ea8b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 14:13:09 +0000 Subject: [PATCH 2/3] feat(docs): generate sitemap dynamically using build entry point Replace the static sitemap.xml with dynamic generation via the new `build` entry point. The sitemap is now derived from the route definitions, ensuring it stays in sync as pages are added or removed. Also adds `outDir` to `BuildEntryContext` so build entries know where to write additional output files. https://claude.ai/code/session_01D7p4ghgU7hqJA6SnMfVtPA --- packages/docs/public/sitemap.xml | 48 --------------------- packages/docs/src/build.ts | 43 ++++++++++++++++++ packages/docs/vite.config.ts | 1 + packages/static/src/build/buildApp.ts | 2 +- packages/static/src/buildEntryDefinition.ts | 5 +++ 5 files changed, 50 insertions(+), 49 deletions(-) delete mode 100644 packages/docs/public/sitemap.xml create mode 100644 packages/docs/src/build.ts 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..02cda4b --- /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/build-entry"; +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 5c8c61c..aadee83 100644 --- a/packages/static/src/build/buildApp.ts +++ b/packages/static/src/build/buildApp.ts @@ -79,7 +79,7 @@ export async function buildApp( } if (entry.buildEntry) { - await entry.buildEntry({ build: doBuild }); + await entry.buildEntry({ build: doBuild, outDir: baseDir }); } else { await doBuild(); } diff --git a/packages/static/src/buildEntryDefinition.ts b/packages/static/src/buildEntryDefinition.ts index a3a0e24..5f0f9b2 100644 --- a/packages/static/src/buildEntryDefinition.ts +++ b/packages/static/src/buildEntryDefinition.ts @@ -8,6 +8,11 @@ export interface BuildEntryContext { * 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; } /** From 0ffa5dc859a5e99552f39f5050e26d1640920a74 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 14:19:05 +0000 Subject: [PATCH 3/3] refactor: export build entry types from server entry instead of separate export Move `BuildEntryContext` and `BuildEntryFunction` type exports from the dedicated `@funstack/static/build-entry` path to `@funstack/static/server`, keeping the public API surface smaller. https://claude.ai/code/session_01D7p4ghgU7hqJA6SnMfVtPA --- packages/docs/src/build.ts | 2 +- packages/static/package.json | 4 ---- packages/static/src/entries/server.ts | 4 ++++ packages/static/tsdown.config.ts | 1 - 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/docs/src/build.ts b/packages/docs/src/build.ts index 02cda4b..620adb5 100644 --- a/packages/docs/src/build.ts +++ b/packages/docs/src/build.ts @@ -1,6 +1,6 @@ import { writeFile } from "node:fs/promises"; import path from "node:path"; -import type { BuildEntryFunction } from "@funstack/static/build-entry"; +import type { BuildEntryFunction } from "@funstack/static/server"; import type { RouteDefinition } from "@funstack/router/server"; import { routes } from "./App"; diff --git a/packages/static/package.json b/packages/static/package.json index 3bd8a46..1807be6 100644 --- a/packages/static/package.json +++ b/packages/static/package.json @@ -27,10 +27,6 @@ "types": "./dist/entryDefinition.d.mts", "import": "./dist/entryDefinition.mjs" }, - "./build-entry": { - "types": "./dist/buildEntryDefinition.d.mts", - "import": "./dist/buildEntryDefinition.mjs" - }, "./server": { "types": "./dist/entries/server.d.mts", "import": "./dist/entries/server.mjs" 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/tsdown.config.ts b/packages/static/tsdown.config.ts index 062653a..8eb5a9d 100644 --- a/packages/static/tsdown.config.ts +++ b/packages/static/tsdown.config.ts @@ -4,7 +4,6 @@ export default defineConfig({ entry: [ "src/index.ts", "src/entryDefinition.ts", - "src/buildEntryDefinition.ts", "src/entries/*.ts", "src/bin/*.ts", ],