Skip to content

Commit f71429a

Browse files
authored
feat: add configurable build entry point (#104)
1 parent e4cc94f commit f71429a

File tree

9 files changed

+155
-90
lines changed

9 files changed

+155
-90
lines changed

packages/docs/public/sitemap.xml

Lines changed: 0 additions & 48 deletions
This file was deleted.

packages/docs/src/build.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { writeFile } from "node:fs/promises";
2+
import path from "node:path";
3+
import type { BuildEntryFunction } from "@funstack/static/server";
4+
import type { RouteDefinition } from "@funstack/router/server";
5+
import { routes } from "./App";
6+
7+
const siteUrl = "https://static.funstack.work";
8+
9+
function collectPaths(routes: RouteDefinition[]): string[] {
10+
const paths: string[] = [];
11+
for (const route of routes) {
12+
if (route.children) {
13+
paths.push(...collectPaths(route.children));
14+
} else if (route.path !== undefined && route.path !== "*") {
15+
paths.push(route.path);
16+
}
17+
}
18+
return paths;
19+
}
20+
21+
function generateSitemap(paths: string[]): string {
22+
const urls = paths
23+
.map((p) => {
24+
const loc = p === "/" ? siteUrl + "/" : `${siteUrl}${p}`;
25+
return ` <url>\n <loc>${loc}</loc>\n </url>`;
26+
})
27+
.join("\n");
28+
29+
return `<?xml version="1.0" encoding="UTF-8"?>
30+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
31+
${urls}
32+
</urlset>
33+
`;
34+
}
35+
36+
export default (async ({ build, outDir }) => {
37+
const paths = collectPaths(routes);
38+
39+
await Promise.all([
40+
build(),
41+
writeFile(path.join(outDir, "sitemap.xml"), generateSitemap(paths)),
42+
]);
43+
}) satisfies BuildEntryFunction;

packages/docs/vite.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export default defineConfig(async () => {
1414
funstackStatic({
1515
entries: "./src/entries.tsx",
1616
ssr: true,
17+
build: "./src/build.ts",
1718
}),
1819
{
1920
// to make .mdx loading lazy

packages/static/src/build/buildApp.ts

Lines changed: 50 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -25,55 +25,63 @@ export async function buildApp(
2525
const baseDir = config.environments.client.build.outDir;
2626
const base = normalizeBase(config.base);
2727

28-
const { entries, deferRegistry } = await entry.build();
29-
30-
// Validate all entry paths
31-
const paths: string[] = [];
32-
for (const result of entries) {
33-
const error = validateEntryPath(result.path);
34-
if (error) {
35-
throw new Error(error);
28+
async function doBuild() {
29+
const { entries, deferRegistry } = await entry.build();
30+
31+
// Validate all entry paths
32+
const paths: string[] = [];
33+
for (const result of entries) {
34+
const error = validateEntryPath(result.path);
35+
if (error) {
36+
throw new Error(error);
37+
}
38+
paths.push(result.path);
39+
}
40+
const dupError = checkDuplicatePaths(paths);
41+
if (dupError) {
42+
throw new Error(dupError);
3643
}
37-
paths.push(result.path);
38-
}
39-
const dupError = checkDuplicatePaths(paths);
40-
if (dupError) {
41-
throw new Error(dupError);
42-
}
43-
44-
// Process all deferred components once across all entries.
45-
// We pass a dummy empty stream since we handle per-entry RSC payloads separately.
46-
const dummyStream = new ReadableStream<Uint8Array>({
47-
start(controller) {
48-
controller.close();
49-
},
50-
});
51-
const { components, idMapping } = await processRscComponents(
52-
deferRegistry.loadAll(),
53-
dummyStream,
54-
options.rscPayloadDir,
55-
context,
56-
);
5744

58-
// Write each entry's HTML and RSC payload
59-
for (const result of entries) {
60-
await buildSingleEntry(
61-
result,
62-
idMapping,
63-
baseDir,
64-
base,
45+
// Process all deferred components once across all entries.
46+
// We pass a dummy empty stream since we handle per-entry RSC payloads separately.
47+
const dummyStream = new ReadableStream<Uint8Array>({
48+
start(controller) {
49+
controller.close();
50+
},
51+
});
52+
const { components, idMapping } = await processRscComponents(
53+
deferRegistry.loadAll(),
54+
dummyStream,
6555
options.rscPayloadDir,
6656
context,
6757
);
58+
59+
// Write each entry's HTML and RSC payload
60+
for (const result of entries) {
61+
await buildSingleEntry(
62+
result,
63+
idMapping,
64+
baseDir,
65+
base,
66+
options.rscPayloadDir,
67+
context,
68+
);
69+
}
70+
71+
// Write all deferred component payloads
72+
for (const { finalId, finalContent, name } of components) {
73+
const filePath = path.join(
74+
baseDir,
75+
getModulePathFor(finalId).replace(/^\//, ""),
76+
);
77+
await writeFileNormal(filePath, finalContent, context, name);
78+
}
6879
}
6980

70-
// Write all deferred component payloads
71-
for (const { finalId, finalContent, name } of components) {
72-
const filePath = path.join(
73-
baseDir,
74-
getModulePathFor(finalId).replace(/^\//, ""),
75-
);
76-
await writeFileNormal(filePath, finalContent, context, name);
81+
if (entry.buildEntry) {
82+
await entry.buildEntry({ build: doBuild, outDir: baseDir });
83+
} else {
84+
await doBuild();
7785
}
7886
}
7987

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* Context passed to the build entry function.
3+
*/
4+
export interface BuildEntryContext {
5+
/**
6+
* Performs the default build flow (rendering entries and writing output files).
7+
* Call this to execute the standard build process.
8+
* You can run additional work before, after, or in parallel with this function.
9+
*/
10+
build: () => Promise<void>;
11+
/**
12+
* Absolute path to the output directory where built files are written.
13+
* Use this to write additional files (e.g. sitemap.xml) alongside the build output.
14+
*/
15+
outDir: string;
16+
}
17+
18+
/**
19+
* The build entry module should default-export a function with this signature.
20+
*/
21+
export type BuildEntryFunction = (
22+
context: BuildEntryContext,
23+
) => Promise<void> | void;
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
11
export { defer, type DeferOptions } from "../rsc/defer";
2+
export type {
3+
BuildEntryContext,
4+
BuildEntryFunction,
5+
} from "../buildEntryDefinition";

packages/static/src/plugin/index.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,19 @@ interface FunstackStaticBaseOptions {
4040
* @default "fun__rsc-payload"
4141
*/
4242
rscPayloadDir?: string;
43+
/**
44+
* Path to a module that customizes the build process.
45+
* The module should `export default` an async function that receives
46+
* `{ build }` where `build` is a function that performs the default
47+
* build flow.
48+
*
49+
* This allows you to run additional work before/after the build,
50+
* or to control the build execution (e.g. parallel work).
51+
* Only called during production builds, not in dev mode.
52+
*
53+
* The module runs in the RSC environment.
54+
*/
55+
build?: string;
4356
}
4457

4558
interface SingleEntryOptions {
@@ -95,6 +108,7 @@ export default function funstackStatic(
95108

96109
let resolvedEntriesModule: string = "__uninitialized__";
97110
let resolvedClientInitEntry: string | undefined;
111+
let resolvedBuildEntry: string | undefined;
98112

99113
// Determine whether user specified entries or root+app
100114
const isMultiEntry = "entries" in options && options.entries !== undefined;
@@ -152,6 +166,11 @@ export default function funstackStatic(
152166
path.resolve(config.root, clientInit),
153167
);
154168
}
169+
if (options.build) {
170+
resolvedBuildEntry = normalizePath(
171+
path.resolve(config.root, options.build),
172+
);
173+
}
155174
},
156175
configEnvironment(_name, config) {
157176
if (!config.optimizeDeps) {
@@ -189,6 +208,9 @@ export default function funstackStatic(
189208
if (id === "virtual:funstack/client-init") {
190209
return "\0virtual:funstack/client-init";
191210
}
211+
if (id === "virtual:funstack/build-entry") {
212+
return "\0virtual:funstack/build-entry";
213+
}
192214
},
193215
load(id) {
194216
if (id === "\0virtual:funstack/entries") {
@@ -218,6 +240,12 @@ export default function funstackStatic(
218240
}
219241
return "";
220242
}
243+
if (id === "\0virtual:funstack/build-entry") {
244+
if (resolvedBuildEntry) {
245+
return `export { default } from "${resolvedBuildEntry}";`;
246+
}
247+
return "export default undefined;";
248+
}
221249
},
222250
},
223251
{

packages/static/src/rsc/entry.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,7 @@ export async function build() {
316316
}
317317

318318
export { defer } from "./defer";
319+
export { default as buildEntry } from "virtual:funstack/build-entry";
319320

320321
if (import.meta.hot) {
321322
import.meta.hot.accept();

packages/static/src/virtual.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,8 @@ declare module "virtual:funstack/config" {
88
export const rscPayloadDir: string;
99
}
1010
declare module "virtual:funstack/client-init" {}
11+
declare module "virtual:funstack/build-entry" {
12+
import type { BuildEntryFunction } from "./buildEntryDefinition";
13+
const buildEntry: BuildEntryFunction | undefined;
14+
export default buildEntry;
15+
}

0 commit comments

Comments
 (0)