@funstack/static 0.0.5 → 0.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/build/buildApp.mjs +35 -6
- package/dist/build/buildApp.mjs.map +1 -1
- package/dist/build/validateEntryPath.mjs +28 -0
- package/dist/build/validateEntryPath.mjs.map +1 -0
- package/dist/docs/GettingStarted.md +1 -0
- package/dist/docs/api/EntryDefinition.md +139 -0
- package/dist/docs/api/FunstackStatic.md +98 -2
- package/dist/docs/index.md +3 -0
- package/dist/docs/learn/DeferAndActivity.md +176 -0
- package/dist/docs/learn/MultipleEntrypoints.md +246 -0
- package/dist/docs/learn/OptimizingPayloads.md +1 -1
- package/dist/entries/rsc-client.d.mts +0 -1
- package/dist/entries/rsc.d.mts +2 -2
- package/dist/entryDefinition.d.mts +44 -0
- package/dist/entryDefinition.d.mts.map +1 -0
- package/dist/entryDefinition.mjs +1 -0
- package/dist/plugin/index.d.mts +26 -19
- package/dist/plugin/index.d.mts.map +1 -1
- package/dist/plugin/index.mjs +25 -9
- package/dist/plugin/index.mjs.map +1 -1
- package/dist/plugin/server.mjs +9 -2
- package/dist/plugin/server.mjs.map +1 -1
- package/dist/rsc/entry.d.mts +13 -7
- package/dist/rsc/entry.d.mts.map +1 -1
- package/dist/rsc/entry.mjs +79 -36
- package/dist/rsc/entry.mjs.map +1 -1
- package/dist/rsc/resolveEntry.mjs +29 -0
- package/dist/rsc/resolveEntry.mjs.map +1 -0
- package/dist/ssr/entry.mjs +1 -1
- package/dist/ssr/entry.mjs.map +1 -1
- package/dist/util/urlPath.mjs +17 -0
- package/dist/util/urlPath.mjs.map +1 -0
- package/package.json +14 -9
- package/dist/rsc-client/entry.d.mts +0 -1
package/README.md
CHANGED
|
@@ -37,7 +37,7 @@ export default defineConfig({
|
|
|
37
37
|
|
|
38
38
|
## Documentation
|
|
39
39
|
|
|
40
|
-
For detailed API documentation and guides, visit the **[Documentation](https://
|
|
40
|
+
For detailed API documentation and guides, visit the **[Documentation](https://static.funstack.work/)**.
|
|
41
41
|
|
|
42
42
|
### :robot: FUNSTACK Static Skill
|
|
43
43
|
|
package/dist/build/buildApp.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { getRscPayloadPath, rscPayloadPlaceholder } from "./rscPath.mjs";
|
|
|
3
3
|
import { drainStream } from "../util/drainStream.mjs";
|
|
4
4
|
import { computeContentHash } from "./contentHash.mjs";
|
|
5
5
|
import { processRscComponents } from "./rscProcessor.mjs";
|
|
6
|
+
import { checkDuplicatePaths, validateEntryPath } from "./validateEntryPath.mjs";
|
|
6
7
|
import path from "node:path";
|
|
7
8
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
8
9
|
import { pathToFileURL } from "node:url";
|
|
@@ -12,16 +13,44 @@ async function buildApp(builder, context) {
|
|
|
12
13
|
const { config } = builder;
|
|
13
14
|
const entry = await import(pathToFileURL(path.join(config.environments.rsc.build.outDir, "index.js")).href);
|
|
14
15
|
const baseDir = config.environments.client.build.outDir;
|
|
15
|
-
const
|
|
16
|
+
const base = normalizeBase(config.base);
|
|
17
|
+
const { entries, deferRegistry } = await entry.build();
|
|
18
|
+
const paths = [];
|
|
19
|
+
for (const result of entries) {
|
|
20
|
+
const error = validateEntryPath(result.path);
|
|
21
|
+
if (error) throw new Error(error);
|
|
22
|
+
paths.push(result.path);
|
|
23
|
+
}
|
|
24
|
+
const dupError = checkDuplicatePaths(paths);
|
|
25
|
+
if (dupError) throw new Error(dupError);
|
|
26
|
+
const dummyStream = new ReadableStream({ start(controller) {
|
|
27
|
+
controller.close();
|
|
28
|
+
} });
|
|
29
|
+
const { components, idMapping } = await processRscComponents(deferRegistry.loadAll(), dummyStream, context);
|
|
30
|
+
for (const result of entries) await buildSingleEntry(result, idMapping, baseDir, base, context);
|
|
31
|
+
for (const { finalId, finalContent, name } of components) await writeFileNormal(path.join(baseDir, getModulePathFor(finalId).replace(/^\//, "")), finalContent, context, name);
|
|
32
|
+
}
|
|
33
|
+
function normalizeBase(base) {
|
|
34
|
+
const normalized = base.endsWith("/") ? base.slice(0, -1) : base;
|
|
35
|
+
return normalized === "/" ? "" : normalized;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Replaces temporary IDs with final hashed IDs in content.
|
|
39
|
+
*/
|
|
40
|
+
function replaceIdsInContent(content, idMapping) {
|
|
41
|
+
let result = content;
|
|
42
|
+
for (const [oldId, newId] of idMapping) if (oldId !== newId) result = result.replaceAll(oldId, newId);
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
async function buildSingleEntry(result, idMapping, baseDir, base, context) {
|
|
46
|
+
const { path: entryPath, html, appRsc } = result;
|
|
16
47
|
const htmlContent = await drainStream(html);
|
|
17
|
-
const
|
|
48
|
+
const appRscContent = replaceIdsInContent(await drainStream(appRsc), idMapping);
|
|
18
49
|
const mainPayloadHash = await computeContentHash(appRscContent);
|
|
19
|
-
const
|
|
20
|
-
const mainPayloadPath = base === "/" ? getRscPayloadPath(mainPayloadHash) : base + getRscPayloadPath(mainPayloadHash);
|
|
50
|
+
const mainPayloadPath = base === "" ? getRscPayloadPath(mainPayloadHash) : base + getRscPayloadPath(mainPayloadHash);
|
|
21
51
|
const finalHtmlContent = htmlContent.replaceAll(rscPayloadPlaceholder, mainPayloadPath);
|
|
22
|
-
await writeFileNormal(path.join(baseDir,
|
|
52
|
+
await writeFileNormal(path.join(baseDir, entryPath), finalHtmlContent, context);
|
|
23
53
|
await writeFileNormal(path.join(baseDir, getRscPayloadPath(mainPayloadHash).replace(/^\//, "")), appRscContent, context);
|
|
24
|
-
for (const { finalId, finalContent, name } of components) await writeFileNormal(path.join(baseDir, getModulePathFor(finalId).replace(/^\//, "")), finalContent, context, name);
|
|
25
54
|
}
|
|
26
55
|
async function writeFileNormal(filePath, data, context, name) {
|
|
27
56
|
await mkdir(path.dirname(filePath), { recursive: true });
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"buildApp.mjs","names":[],"sources":["../../src/build/buildApp.ts"],"sourcesContent":["import { mkdir, writeFile } from \"node:fs/promises\";\nimport path from \"node:path\";\nimport { pathToFileURL } from \"node:url\";\nimport type { ViteBuilder, MinimalPluginContextWithoutEnvironment } from \"vite\";\nimport { rscPayloadPlaceholder, getRscPayloadPath } from \"./rscPath\";\nimport { getModulePathFor } from \"../rsc/rscModule\";\nimport { processRscComponents } from \"./rscProcessor\";\nimport { computeContentHash } from \"./contentHash\";\nimport { drainStream } from \"../util/drainStream\";\n\nexport async function buildApp(\n builder: ViteBuilder,\n context: MinimalPluginContextWithoutEnvironment,\n) {\n const { config } = builder;\n // import server entry\n const entryPath = path.join(config.environments.rsc.build.outDir, \"index.js\");\n const entry: typeof import(\"../rsc/entry\") = await import(\n pathToFileURL(entryPath).href\n );\n\n
|
|
1
|
+
{"version":3,"file":"buildApp.mjs","names":[],"sources":["../../src/build/buildApp.ts"],"sourcesContent":["import { mkdir, writeFile } from \"node:fs/promises\";\nimport path from \"node:path\";\nimport { pathToFileURL } from \"node:url\";\nimport type { ViteBuilder, MinimalPluginContextWithoutEnvironment } from \"vite\";\nimport { rscPayloadPlaceholder, getRscPayloadPath } from \"./rscPath\";\nimport { getModulePathFor } from \"../rsc/rscModule\";\nimport { processRscComponents } from \"./rscProcessor\";\nimport { computeContentHash } from \"./contentHash\";\nimport { drainStream } from \"../util/drainStream\";\nimport { validateEntryPath, checkDuplicatePaths } from \"./validateEntryPath\";\nimport type { EntryBuildResult } from \"../rsc/entry\";\n\nexport async function buildApp(\n builder: ViteBuilder,\n context: MinimalPluginContextWithoutEnvironment,\n) {\n const { config } = builder;\n // import server entry\n const entryPath = path.join(config.environments.rsc.build.outDir, \"index.js\");\n const entry: typeof import(\"../rsc/entry\") = await import(\n pathToFileURL(entryPath).href\n );\n\n const baseDir = config.environments.client.build.outDir;\n const base = normalizeBase(config.base);\n\n const { entries, deferRegistry } = await entry.build();\n\n // Validate all entry paths\n const paths: string[] = [];\n for (const result of entries) {\n const error = validateEntryPath(result.path);\n if (error) {\n throw new Error(error);\n }\n paths.push(result.path);\n }\n const dupError = checkDuplicatePaths(paths);\n if (dupError) {\n throw new Error(dupError);\n }\n\n // Process all deferred components once across all entries.\n // We pass a dummy empty stream since we handle per-entry RSC payloads separately.\n const dummyStream = new ReadableStream<Uint8Array>({\n start(controller) {\n controller.close();\n },\n });\n const { components, idMapping } = await processRscComponents(\n deferRegistry.loadAll(),\n dummyStream,\n context,\n );\n\n // Write each entry's HTML and RSC payload\n for (const result of entries) {\n await buildSingleEntry(result, idMapping, baseDir, base, context);\n }\n\n // Write all deferred component payloads\n for (const { finalId, finalContent, name } of components) {\n const filePath = path.join(\n baseDir,\n getModulePathFor(finalId).replace(/^\\//, \"\"),\n );\n await writeFileNormal(filePath, finalContent, context, name);\n }\n}\n\nfunction normalizeBase(base: string): string {\n const normalized = base.endsWith(\"/\") ? base.slice(0, -1) : base;\n return normalized === \"/\" ? \"\" : normalized;\n}\n\n/**\n * Replaces temporary IDs with final hashed IDs in content.\n */\nfunction replaceIdsInContent(\n content: string,\n idMapping: Map<string, string>,\n): string {\n let result = content;\n for (const [oldId, newId] of idMapping) {\n if (oldId !== newId) {\n result = result.replaceAll(oldId, newId);\n }\n }\n return result;\n}\n\nasync function buildSingleEntry(\n result: EntryBuildResult,\n idMapping: Map<string, string>,\n baseDir: string,\n base: string,\n context: MinimalPluginContextWithoutEnvironment,\n) {\n const { path: entryPath, html, appRsc } = result;\n\n // Drain HTML stream to string\n const htmlContent = await drainStream(html);\n\n // Drain and process RSC payload: replace temp IDs with final hashed IDs\n const rawAppRscContent = await drainStream(appRsc);\n const appRscContent = replaceIdsInContent(rawAppRscContent, idMapping);\n\n // Compute hash for this entry's RSC payload\n const mainPayloadHash = await computeContentHash(appRscContent);\n const mainPayloadPath =\n base === \"\"\n ? getRscPayloadPath(mainPayloadHash)\n : base + getRscPayloadPath(mainPayloadHash);\n\n // Replace placeholder with final hashed path\n const finalHtmlContent = htmlContent.replaceAll(\n rscPayloadPlaceholder,\n mainPayloadPath,\n );\n\n // entryPath is already a file name (e.g. \"index.html\", \"about.html\")\n await writeFileNormal(\n path.join(baseDir, entryPath),\n finalHtmlContent,\n context,\n );\n\n // Write RSC payload with hashed filename\n await writeFileNormal(\n path.join(baseDir, getRscPayloadPath(mainPayloadHash).replace(/^\\//, \"\")),\n appRscContent,\n context,\n );\n}\n\nasync function writeFileNormal(\n filePath: string,\n data: string,\n context: MinimalPluginContextWithoutEnvironment,\n name?: string,\n) {\n await mkdir(path.dirname(filePath), { recursive: true });\n const nameInfo = name ? ` (${name})` : \"\";\n context.info(`[funstack] Writing ${filePath}${nameInfo}`);\n await writeFile(filePath, data);\n}\n"],"mappings":";;;;;;;;;;;AAYA,eAAsB,SACpB,SACA,SACA;CACA,MAAM,EAAE,WAAW;CAGnB,MAAM,QAAuC,MAAM,OACjD,cAFgB,KAAK,KAAK,OAAO,aAAa,IAAI,MAAM,QAAQ,WAAW,CAEnD,CAAC;CAG3B,MAAM,UAAU,OAAO,aAAa,OAAO,MAAM;CACjD,MAAM,OAAO,cAAc,OAAO,KAAK;CAEvC,MAAM,EAAE,SAAS,kBAAkB,MAAM,MAAM,OAAO;CAGtD,MAAM,QAAkB,EAAE;AAC1B,MAAK,MAAM,UAAU,SAAS;EAC5B,MAAM,QAAQ,kBAAkB,OAAO,KAAK;AAC5C,MAAI,MACF,OAAM,IAAI,MAAM,MAAM;AAExB,QAAM,KAAK,OAAO,KAAK;;CAEzB,MAAM,WAAW,oBAAoB,MAAM;AAC3C,KAAI,SACF,OAAM,IAAI,MAAM,SAAS;CAK3B,MAAM,cAAc,IAAI,eAA2B,EACjD,MAAM,YAAY;AAChB,aAAW,OAAO;IAErB,CAAC;CACF,MAAM,EAAE,YAAY,cAAc,MAAM,qBACtC,cAAc,SAAS,EACvB,aACA,QACD;AAGD,MAAK,MAAM,UAAU,QACnB,OAAM,iBAAiB,QAAQ,WAAW,SAAS,MAAM,QAAQ;AAInE,MAAK,MAAM,EAAE,SAAS,cAAc,UAAU,WAK5C,OAAM,gBAJW,KAAK,KACpB,SACA,iBAAiB,QAAQ,CAAC,QAAQ,OAAO,GAAG,CAC7C,EAC+B,cAAc,SAAS,KAAK;;AAIhE,SAAS,cAAc,MAAsB;CAC3C,MAAM,aAAa,KAAK,SAAS,IAAI,GAAG,KAAK,MAAM,GAAG,GAAG,GAAG;AAC5D,QAAO,eAAe,MAAM,KAAK;;;;;AAMnC,SAAS,oBACP,SACA,WACQ;CACR,IAAI,SAAS;AACb,MAAK,MAAM,CAAC,OAAO,UAAU,UAC3B,KAAI,UAAU,MACZ,UAAS,OAAO,WAAW,OAAO,MAAM;AAG5C,QAAO;;AAGT,eAAe,iBACb,QACA,WACA,SACA,MACA,SACA;CACA,MAAM,EAAE,MAAM,WAAW,MAAM,WAAW;CAG1C,MAAM,cAAc,MAAM,YAAY,KAAK;CAI3C,MAAM,gBAAgB,oBADG,MAAM,YAAY,OAAO,EACU,UAAU;CAGtE,MAAM,kBAAkB,MAAM,mBAAmB,cAAc;CAC/D,MAAM,kBACJ,SAAS,KACL,kBAAkB,gBAAgB,GAClC,OAAO,kBAAkB,gBAAgB;CAG/C,MAAM,mBAAmB,YAAY,WACnC,uBACA,gBACD;AAGD,OAAM,gBACJ,KAAK,KAAK,SAAS,UAAU,EAC7B,kBACA,QACD;AAGD,OAAM,gBACJ,KAAK,KAAK,SAAS,kBAAkB,gBAAgB,CAAC,QAAQ,OAAO,GAAG,CAAC,EACzE,eACA,QACD;;AAGH,eAAe,gBACb,UACA,MACA,SACA,MACA;AACA,OAAM,MAAM,KAAK,QAAQ,SAAS,EAAE,EAAE,WAAW,MAAM,CAAC;CACxD,MAAM,WAAW,OAAO,KAAK,KAAK,KAAK;AACvC,SAAQ,KAAK,sBAAsB,WAAW,WAAW;AACzD,OAAM,UAAU,UAAU,KAAK"}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
//#region src/build/validateEntryPath.ts
|
|
2
|
+
/**
|
|
3
|
+
* Validates an entry path string.
|
|
4
|
+
* - Must end with ".html"
|
|
5
|
+
* - Must not start with "/"
|
|
6
|
+
*
|
|
7
|
+
* @returns An error message if invalid, or undefined if valid.
|
|
8
|
+
*/
|
|
9
|
+
function validateEntryPath(entryPath) {
|
|
10
|
+
if (entryPath.startsWith("/")) return `Entry path must not start with "/": "${entryPath}". Paths are relative to the output directory.`;
|
|
11
|
+
if (!entryPath.endsWith(".html")) return `Entry path must end with ".html": "${entryPath}"`;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Checks an array of entry paths for duplicates.
|
|
15
|
+
*
|
|
16
|
+
* @returns An error message if duplicates found, or undefined if all unique.
|
|
17
|
+
*/
|
|
18
|
+
function checkDuplicatePaths(paths) {
|
|
19
|
+
const seen = /* @__PURE__ */ new Set();
|
|
20
|
+
for (const p of paths) {
|
|
21
|
+
if (seen.has(p)) return `Duplicate entry path: "${p}"`;
|
|
22
|
+
seen.add(p);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
//#endregion
|
|
27
|
+
export { checkDuplicatePaths, validateEntryPath };
|
|
28
|
+
//# sourceMappingURL=validateEntryPath.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"validateEntryPath.mjs","names":[],"sources":["../../src/build/validateEntryPath.ts"],"sourcesContent":["/**\n * Validates an entry path string.\n * - Must end with \".html\"\n * - Must not start with \"/\"\n *\n * @returns An error message if invalid, or undefined if valid.\n */\nexport function validateEntryPath(entryPath: string): string | undefined {\n if (entryPath.startsWith(\"/\")) {\n return `Entry path must not start with \"/\": \"${entryPath}\". Paths are relative to the output directory.`;\n }\n if (!entryPath.endsWith(\".html\")) {\n return `Entry path must end with \".html\": \"${entryPath}\"`;\n }\n return undefined;\n}\n\n/**\n * Checks an array of entry paths for duplicates.\n *\n * @returns An error message if duplicates found, or undefined if all unique.\n */\nexport function checkDuplicatePaths(paths: string[]): string | undefined {\n const seen = new Set<string>();\n for (const p of paths) {\n if (seen.has(p)) {\n return `Duplicate entry path: \"${p}\"`;\n }\n seen.add(p);\n }\n return undefined;\n}\n"],"mappings":";;;;;;;;AAOA,SAAgB,kBAAkB,WAAuC;AACvE,KAAI,UAAU,WAAW,IAAI,CAC3B,QAAO,wCAAwC,UAAU;AAE3D,KAAI,CAAC,UAAU,SAAS,QAAQ,CAC9B,QAAO,sCAAsC,UAAU;;;;;;;AAU3D,SAAgB,oBAAoB,OAAqC;CACvE,MAAM,uBAAO,IAAI,KAAa;AAC9B,MAAK,MAAM,KAAK,OAAO;AACrB,MAAI,KAAK,IAAI,EAAE,CACb,QAAO,0BAA0B,EAAE;AAErC,OAAK,IAAI,EAAE"}
|
|
@@ -177,4 +177,5 @@ This registers the `funstack-static-knowledge` skill, which provides your AI ass
|
|
|
177
177
|
|
|
178
178
|
- Learn about the [funstackStatic() Plugin API](/funstack-static/api/funstack-static) for configuration options
|
|
179
179
|
- Understand [defer()](/funstack-static/api/defer) for Server Component chunk splitting
|
|
180
|
+
- Build multi-page static sites with [Multiple Entrypoints](/funstack-static/learn/multiple-entrypoints)
|
|
180
181
|
- Dive into [React Server Components](/funstack-static/learn/rsc) concepts
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# EntryDefinition
|
|
2
|
+
|
|
3
|
+
The `EntryDefinition` type defines a single entry in a multi-page static site. Each entry produces one HTML file with its own RSC payload.
|
|
4
|
+
|
|
5
|
+
## Import
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
import type { EntryDefinition } from "@funstack/static/entries";
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```tsx
|
|
14
|
+
import type { EntryDefinition } from "@funstack/static/entries";
|
|
15
|
+
|
|
16
|
+
export default function getEntries(): EntryDefinition[] {
|
|
17
|
+
return [
|
|
18
|
+
{
|
|
19
|
+
path: "index.html",
|
|
20
|
+
root: () => import("./root"),
|
|
21
|
+
app: () => import("./pages/Home"),
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
path: "about.html",
|
|
25
|
+
root: () => import("./root"),
|
|
26
|
+
app: () => import("./pages/About"),
|
|
27
|
+
},
|
|
28
|
+
];
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Type Definition
|
|
33
|
+
|
|
34
|
+
```typescript
|
|
35
|
+
interface EntryDefinition {
|
|
36
|
+
path: string;
|
|
37
|
+
root: MaybePromise<RootModule> | (() => MaybePromise<RootModule>);
|
|
38
|
+
app: ReactNode | MaybePromise<AppModule> | (() => MaybePromise<AppModule>);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type MaybePromise<T> = T | Promise<T>;
|
|
42
|
+
type RootModule = {
|
|
43
|
+
default: React.ComponentType<{ children: React.ReactNode }>;
|
|
44
|
+
};
|
|
45
|
+
type AppModule = { default: React.ComponentType };
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Properties
|
|
49
|
+
|
|
50
|
+
### path
|
|
51
|
+
|
|
52
|
+
**Type:** `string`
|
|
53
|
+
|
|
54
|
+
Output file path relative to the build output directory.
|
|
55
|
+
|
|
56
|
+
- Must end with `.html`
|
|
57
|
+
- Must not start with `/`
|
|
58
|
+
- Duplicate paths cause a build error
|
|
59
|
+
|
|
60
|
+
```typescript
|
|
61
|
+
"index.html"; // -> /
|
|
62
|
+
"about.html"; // -> /about
|
|
63
|
+
"blog/post-1.html"; // -> /blog/post-1
|
|
64
|
+
"blog/post-1/index.html"; // -> /blog/post-1/
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### root
|
|
68
|
+
|
|
69
|
+
**Type:** `MaybePromise<RootModule> | (() => MaybePromise<RootModule>)`
|
|
70
|
+
|
|
71
|
+
The root component module. The module must have a `default` export of a component that accepts `{ children: React.ReactNode }`.
|
|
72
|
+
|
|
73
|
+
```tsx
|
|
74
|
+
// Lazy import (recommended)
|
|
75
|
+
root: () => import("./root"),
|
|
76
|
+
|
|
77
|
+
// Synchronous module object
|
|
78
|
+
import Root from "./root";
|
|
79
|
+
root: { default: Root },
|
|
80
|
+
|
|
81
|
+
// Promise
|
|
82
|
+
root: import("./root"),
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Using lazy imports (`() => import(...)`) is recommended to keep memory usage low when generating many entries.
|
|
86
|
+
|
|
87
|
+
### app
|
|
88
|
+
|
|
89
|
+
**Type:** `ReactNode | MaybePromise<AppModule> | (() => MaybePromise<AppModule>)`
|
|
90
|
+
|
|
91
|
+
The app content for this entry. Accepts three forms:
|
|
92
|
+
|
|
93
|
+
**Module (sync or lazy)** -- the module must have a `default` export component:
|
|
94
|
+
|
|
95
|
+
```tsx
|
|
96
|
+
// Lazy import
|
|
97
|
+
app: () => import("./pages/Home"),
|
|
98
|
+
|
|
99
|
+
// Synchronous module
|
|
100
|
+
import Home from "./pages/Home";
|
|
101
|
+
app: { default: Home },
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
**React node** -- server component JSX rendered directly:
|
|
105
|
+
|
|
106
|
+
```tsx
|
|
107
|
+
app: <BlogPost slug="hello-world" content={content} />,
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The React node form enables parameterized rendering for SSG, where each entry passes different data to the same component without needing a separate module file.
|
|
111
|
+
|
|
112
|
+
## Return Type of getEntries
|
|
113
|
+
|
|
114
|
+
The entries function can return either a synchronous iterable or an async iterable:
|
|
115
|
+
|
|
116
|
+
```typescript
|
|
117
|
+
type GetEntriesResult =
|
|
118
|
+
| Iterable<EntryDefinition>
|
|
119
|
+
| AsyncIterable<EntryDefinition>;
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
This allows returning arrays, generators, or async generators:
|
|
123
|
+
|
|
124
|
+
```tsx
|
|
125
|
+
// Array
|
|
126
|
+
export default function getEntries(): EntryDefinition[] {
|
|
127
|
+
return [{ path: "index.html", root: ..., app: ... }];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Async generator
|
|
131
|
+
export default async function* getEntries() {
|
|
132
|
+
yield { path: "index.html", root: ..., app: ... };
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## See Also
|
|
137
|
+
|
|
138
|
+
- [Multiple Entrypoints](/funstack-static/learn/multiple-entrypoints) - Guide and examples
|
|
139
|
+
- [funstackStatic()](/funstack-static/api/funstack-static) - Plugin configuration
|
|
@@ -10,6 +10,12 @@ import funstackStatic from "@funstack/static";
|
|
|
10
10
|
|
|
11
11
|
## Usage
|
|
12
12
|
|
|
13
|
+
There are two configuration modes: **single-entry** (one HTML page) and **multiple entries** (multiple HTML pages).
|
|
14
|
+
|
|
15
|
+
### Single-Entry Mode
|
|
16
|
+
|
|
17
|
+
Use `root` and `app` to produce a single `index.html`:
|
|
18
|
+
|
|
13
19
|
```typescript
|
|
14
20
|
// vite.config.ts
|
|
15
21
|
import funstackStatic from "@funstack/static";
|
|
@@ -25,14 +31,41 @@ export default defineConfig({
|
|
|
25
31
|
});
|
|
26
32
|
```
|
|
27
33
|
|
|
34
|
+
### Multiple Entries Mode
|
|
35
|
+
|
|
36
|
+
Use `entries` to produce multiple HTML pages from a single project:
|
|
37
|
+
|
|
38
|
+
```typescript
|
|
39
|
+
// vite.config.ts
|
|
40
|
+
import funstackStatic from "@funstack/static";
|
|
41
|
+
import react from "@vitejs/plugin-react";
|
|
42
|
+
import { defineConfig } from "vite";
|
|
43
|
+
|
|
44
|
+
export default defineConfig({
|
|
45
|
+
plugins: [
|
|
46
|
+
funstackStatic({
|
|
47
|
+
entries: "./src/entries.tsx",
|
|
48
|
+
}),
|
|
49
|
+
react(),
|
|
50
|
+
],
|
|
51
|
+
});
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
See [Multiple Entrypoints](/funstack-static/learn/multiple-entrypoints) for a full guide.
|
|
55
|
+
|
|
28
56
|
## Options
|
|
29
57
|
|
|
30
|
-
|
|
58
|
+
The plugin accepts either `root` + `app` (single-entry) or `entries` (multiple entries). These two modes are mutually exclusive.
|
|
59
|
+
|
|
60
|
+
### root
|
|
31
61
|
|
|
32
62
|
**Type:** `string`
|
|
63
|
+
**Required in:** single-entry mode
|
|
33
64
|
|
|
34
65
|
Path to the root component file. This component wraps your entire application and defines the HTML document structure (`<html>`, `<head>`, `<body>`).
|
|
35
66
|
|
|
67
|
+
Cannot be used together with `entries`.
|
|
68
|
+
|
|
36
69
|
```typescript
|
|
37
70
|
funstackStatic({
|
|
38
71
|
root: "./src/root.tsx",
|
|
@@ -57,12 +90,15 @@ export default function Root({ children }: { children: React.ReactNode }) {
|
|
|
57
90
|
}
|
|
58
91
|
```
|
|
59
92
|
|
|
60
|
-
### app
|
|
93
|
+
### app
|
|
61
94
|
|
|
62
95
|
**Type:** `string`
|
|
96
|
+
**Required in:** single-entry mode
|
|
63
97
|
|
|
64
98
|
Path to the app component file. This component defines your application's content.
|
|
65
99
|
|
|
100
|
+
Cannot be used together with `entries`.
|
|
101
|
+
|
|
66
102
|
```typescript
|
|
67
103
|
funstackStatic({
|
|
68
104
|
root: "./src/root.tsx",
|
|
@@ -85,6 +121,45 @@ export default function App() {
|
|
|
85
121
|
|
|
86
122
|
**Note:** if your app has multiple pages, you can use a routing library here just like in a traditional SPA.
|
|
87
123
|
|
|
124
|
+
### entries
|
|
125
|
+
|
|
126
|
+
**Type:** `string`
|
|
127
|
+
**Required in:** multiple entries mode
|
|
128
|
+
|
|
129
|
+
Path to an entries module that exports a function returning entry definitions. Each entry produces its own HTML file.
|
|
130
|
+
|
|
131
|
+
Cannot be used together with `root` or `app`.
|
|
132
|
+
|
|
133
|
+
```typescript
|
|
134
|
+
funstackStatic({
|
|
135
|
+
entries: "./src/entries.tsx",
|
|
136
|
+
});
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
The entries module must default-export a function that returns entry definitions:
|
|
140
|
+
|
|
141
|
+
```tsx
|
|
142
|
+
// src/entries.tsx
|
|
143
|
+
import type { EntryDefinition } from "@funstack/static/entries";
|
|
144
|
+
|
|
145
|
+
export default function getEntries(): EntryDefinition[] {
|
|
146
|
+
return [
|
|
147
|
+
{
|
|
148
|
+
path: "index.html",
|
|
149
|
+
root: () => import("./root"),
|
|
150
|
+
app: () => import("./pages/Home"),
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
path: "about.html",
|
|
154
|
+
root: () => import("./root"),
|
|
155
|
+
app: () => import("./pages/About"),
|
|
156
|
+
},
|
|
157
|
+
];
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
See [Multiple Entrypoints](/funstack-static/learn/multiple-entrypoints) for details on the `EntryDefinition` type and advanced usage patterns like async generators.
|
|
162
|
+
|
|
88
163
|
### publicOutDir (optional)
|
|
89
164
|
|
|
90
165
|
**Type:** `string`
|
|
@@ -153,6 +228,8 @@ Sentry.init({
|
|
|
153
228
|
|
|
154
229
|
## Full Example
|
|
155
230
|
|
|
231
|
+
### Single-Entry
|
|
232
|
+
|
|
156
233
|
```typescript
|
|
157
234
|
// vite.config.ts
|
|
158
235
|
import { funstackStatic } from "@funstack/static";
|
|
@@ -169,6 +246,24 @@ export default defineConfig({
|
|
|
169
246
|
});
|
|
170
247
|
```
|
|
171
248
|
|
|
249
|
+
### Multiple Entries
|
|
250
|
+
|
|
251
|
+
```typescript
|
|
252
|
+
// vite.config.ts
|
|
253
|
+
import funstackStatic from "@funstack/static";
|
|
254
|
+
import react from "@vitejs/plugin-react";
|
|
255
|
+
import { defineConfig } from "vite";
|
|
256
|
+
|
|
257
|
+
export default defineConfig({
|
|
258
|
+
plugins: [
|
|
259
|
+
funstackStatic({
|
|
260
|
+
entries: "./src/entries.tsx",
|
|
261
|
+
}),
|
|
262
|
+
react(),
|
|
263
|
+
],
|
|
264
|
+
});
|
|
265
|
+
```
|
|
266
|
+
|
|
172
267
|
## Vite Commands
|
|
173
268
|
|
|
174
269
|
You can use the same Vite commands you would use in a normal Vite project:
|
|
@@ -180,5 +275,6 @@ You can use the same Vite commands you would use in a normal Vite project:
|
|
|
180
275
|
## See Also
|
|
181
276
|
|
|
182
277
|
- [Getting Started](/funstack-static/getting-started) - Quick start guide
|
|
278
|
+
- [Multiple Entrypoints](/funstack-static/learn/multiple-entrypoints) - Multi-page static site generation
|
|
183
279
|
- [defer()](/funstack-static/api/defer) - Deferred rendering for streaming
|
|
184
280
|
- [React Server Components](/funstack-static/learn/rsc) - Understanding RSC
|
package/dist/docs/index.md
CHANGED
|
@@ -11,12 +11,15 @@ A Vite plugin for building static sites with React Server Components.
|
|
|
11
11
|
### API
|
|
12
12
|
|
|
13
13
|
- [defer()](./api/Defer.md) - The `defer()` function enables deferred rendering for React Server Components, reducing initial data load.
|
|
14
|
+
- [EntryDefinition](./api/EntryDefinition.md) - The `EntryDefinition` type defines a single entry in a multi-page static site. Each entry produces one HTML file with its own RSC payload.
|
|
14
15
|
- [funstackStatic()](./api/FunstackStatic.md) - The `funstackStatic()` function is the main Vite plugin that enables React Server Components for building SPAs without a runtime server.
|
|
15
16
|
|
|
16
17
|
### Learn
|
|
17
18
|
|
|
19
|
+
- [Prefetching with defer() and Activity](./learn/DeferAndActivity.md) - When using `defer()` to split RSC payloads, content is fetched on-demand as it renders. But what if you want to start fetching _before_ the user actually needs it? By combining `defer()` with React 19's `<Activity>` component, you can prefetch deferred payloads in the background so they're ready instantly when revealed.
|
|
18
20
|
- [How It Works](./learn/HowItWorks.md) - FUNSTACK Static is a React framework that leverages React Server Components (RSC) to build a fully static Single Page Application. The result is a set of files that can be deployed to **any static file hosting service** - no server required at runtime.
|
|
19
21
|
- [Using lazy() in Server Components](./learn/LazyServerComponents.md) - React's `lazy()` API is typically associated with client-side code splitting. However, it can also be used in server environments to reduce the initial response time of the development server by deferring the work needed to compute your application.
|
|
22
|
+
- [Multiple Entrypoints](./learn/MultipleEntrypoints.md) - By default, FUNSTACK Static produces a single `index.html` from one `root` + `app` pair. The **multiple entries** feature lets you produce multiple HTML pages from a single project, targeting SSG (Static Site Generation) use cases where a site has distinct pages like `index.html`, `about.html`, and `blog/post-1.html`.
|
|
20
23
|
- [Optimizing RSC Payloads](./learn/OptimizingPayloads.md) - FUNSTACK Static uses React Server Components (RSC) to pre-render your application at build time. By default, all content is bundled into a single RSC payload. This page explains how to split that payload into smaller chunks for better loading performance.
|
|
21
24
|
- [React Server Components](./learn/RSC.md) - [React Server Components (RSC)](https://react.dev/reference/rsc/server-components) are a new paradigm for building React applications where components can run on the server (or at build time) rather than in the browser.
|
|
22
25
|
- [Server-Side Rendering](./learn/SSR.md) - In FUNSTACK Static, **Server-Side Rendering (SSR)** means a build-time process that pre-renders your React components (including client components) to HTML. This can make the initial paint faster.
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# Prefetching with defer() and Activity
|
|
2
|
+
|
|
3
|
+
When using `defer()` to split RSC payloads, content is fetched on-demand as it renders. But what if you want to start fetching _before_ the user actually needs it? By combining `defer()` with React 19's `<Activity>` component, you can prefetch deferred payloads in the background so they're ready instantly when revealed.
|
|
4
|
+
|
|
5
|
+
## What Is Activity?
|
|
6
|
+
|
|
7
|
+
`<Activity>` is a React 19 component that controls whether its children are visible or hidden:
|
|
8
|
+
|
|
9
|
+
```tsx
|
|
10
|
+
import { Activity } from "react";
|
|
11
|
+
|
|
12
|
+
<Activity mode="visible">
|
|
13
|
+
<Panel /> {/* Rendered and visible */}
|
|
14
|
+
</Activity>
|
|
15
|
+
|
|
16
|
+
<Activity mode="hidden">
|
|
17
|
+
<Panel /> {/* Rendered but not visible in the DOM */}
|
|
18
|
+
</Activity>
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
When `mode` is `"hidden"`, React still renders the children, but the output is hidden from the user and effects are not run until the `mode` becomes `"visible"`. The hidden content is rendered at a lower priority so it doesn't affect the performance of visible content. This is useful for keeping off-screen UI alive (preserving state, pre-warming components) without showing it.
|
|
22
|
+
|
|
23
|
+
## How defer() and Activity Work Together
|
|
24
|
+
|
|
25
|
+
Recall that `defer()` wraps a server component so its RSC payload is fetched separately from the main payload. The fetch starts when the deferred component renders on the client.
|
|
26
|
+
|
|
27
|
+
The key insight is: **rendering under `<Activity mode="hidden">` still triggers the fetch**. The deferred component renders (starting the network request for its RSC payload), but the result isn't displayed. When you later switch the Activity to `"visible"`, the content appears immediately because the payload has already been downloaded.
|
|
28
|
+
|
|
29
|
+
1. `<Activity mode="hidden">` renders defer()'ed content
|
|
30
|
+
2. Client starts fetching the RSC payload in the background
|
|
31
|
+
3. Payload arrives and is cached
|
|
32
|
+
4. User triggers the UI to become visible
|
|
33
|
+
5. `<Activity mode="visible">` — content shows instantly, no loading state
|
|
34
|
+
|
|
35
|
+
## Example: Tabbed Interface
|
|
36
|
+
|
|
37
|
+
Consider a tabbed interface where each tab contains heavy server-rendered content. Without prefetching, switching tabs shows a loading spinner while the payload is fetched. With `<Activity>`, you can prefetch all tabs on initial load.
|
|
38
|
+
|
|
39
|
+
```tsx
|
|
40
|
+
"use client";
|
|
41
|
+
|
|
42
|
+
import { Activity, Suspense, useState } from "react";
|
|
43
|
+
|
|
44
|
+
export function Tabs({
|
|
45
|
+
tabs,
|
|
46
|
+
}: {
|
|
47
|
+
tabs: { label: string; content: React.ReactNode }[];
|
|
48
|
+
}) {
|
|
49
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div>
|
|
53
|
+
<div role="tablist">
|
|
54
|
+
{tabs.map((tab, i) => (
|
|
55
|
+
<button
|
|
56
|
+
key={i}
|
|
57
|
+
role="tab"
|
|
58
|
+
aria-selected={i === activeIndex}
|
|
59
|
+
onClick={() => setActiveIndex(i)}
|
|
60
|
+
>
|
|
61
|
+
{tab.label}
|
|
62
|
+
</button>
|
|
63
|
+
))}
|
|
64
|
+
</div>
|
|
65
|
+
{tabs.map((tab, i) => (
|
|
66
|
+
<Activity key={i} mode={i === activeIndex ? "visible" : "hidden"}>
|
|
67
|
+
<div role="tabpanel">
|
|
68
|
+
<Suspense fallback={<p>Loading...</p>}>{tab.content}</Suspense>
|
|
69
|
+
</div>
|
|
70
|
+
</Activity>
|
|
71
|
+
))}
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
On the server side, each tab's content is wrapped with `defer()`:
|
|
78
|
+
|
|
79
|
+
```tsx
|
|
80
|
+
import { defer } from "@funstack/static/server";
|
|
81
|
+
import { Tabs } from "./Tabs";
|
|
82
|
+
import Overview from "./tabs/Overview";
|
|
83
|
+
import Specifications from "./tabs/Specifications";
|
|
84
|
+
import Reviews from "./tabs/Reviews";
|
|
85
|
+
|
|
86
|
+
function ProductPage() {
|
|
87
|
+
return (
|
|
88
|
+
<Tabs
|
|
89
|
+
tabs={[
|
|
90
|
+
{
|
|
91
|
+
label: "Overview",
|
|
92
|
+
content: defer(<Overview />, { name: "Overview" }),
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
label: "Specifications",
|
|
96
|
+
content: defer(<Specifications />, { name: "Specifications" }),
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
label: "Reviews",
|
|
100
|
+
content: defer(<Reviews />, { name: "Reviews" }),
|
|
101
|
+
},
|
|
102
|
+
]}
|
|
103
|
+
/>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
When this page loads:
|
|
109
|
+
|
|
110
|
+
1. The active tab ("Overview") renders visibly and fetches its payload
|
|
111
|
+
2. The hidden tabs ("Specifications", "Reviews") also render under `<Activity mode="hidden">`, triggering their payload fetches in the background
|
|
112
|
+
3. When the user clicks "Specifications", it appears instantly — no spinner
|
|
113
|
+
|
|
114
|
+
## Example: Collapsible Sections
|
|
115
|
+
|
|
116
|
+
Another common pattern is pre-fetching content inside a collapsible section:
|
|
117
|
+
|
|
118
|
+
```tsx
|
|
119
|
+
"use client";
|
|
120
|
+
|
|
121
|
+
import { Activity, Suspense, useState } from "react";
|
|
122
|
+
|
|
123
|
+
export function Collapsible({
|
|
124
|
+
title,
|
|
125
|
+
children,
|
|
126
|
+
}: {
|
|
127
|
+
title: string;
|
|
128
|
+
children: React.ReactNode;
|
|
129
|
+
}) {
|
|
130
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<div>
|
|
134
|
+
<button onClick={() => setIsOpen(!isOpen)}>{title}</button>
|
|
135
|
+
<Activity mode={isOpen ? "visible" : "hidden"}>
|
|
136
|
+
<Suspense fallback={<p>Loading...</p>}>{children}</Suspense>
|
|
137
|
+
</Activity>
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
```tsx
|
|
144
|
+
import { defer } from "@funstack/static/server";
|
|
145
|
+
import { Collapsible } from "./Collapsible";
|
|
146
|
+
import DetailedSpecs from "./DetailedSpecs";
|
|
147
|
+
|
|
148
|
+
function Product() {
|
|
149
|
+
return (
|
|
150
|
+
<Collapsible title="Show detailed specifications">
|
|
151
|
+
{defer(<DetailedSpecs />, { name: "DetailedSpecs" })}
|
|
152
|
+
</Collapsible>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Even though the section starts collapsed, `<Activity mode="hidden">` causes the deferred content to start fetching immediately. When the user expands the section, the content is already there.
|
|
158
|
+
|
|
159
|
+
## When to Use This Pattern
|
|
160
|
+
|
|
161
|
+
This pattern works best when:
|
|
162
|
+
|
|
163
|
+
- **Content is likely to be needed soon** — If the user will probably view a tab or expand a section, prefetching eliminates the wait.
|
|
164
|
+
- **Payload sizes are reasonable** — Prefetching multiple large payloads may waste bandwidth if the user never views them. Use judgment based on your typical user behavior.
|
|
165
|
+
- **You want instant transitions** — Tabs, accordions, and similar reveal-based UI patterns benefit most from this approach.
|
|
166
|
+
|
|
167
|
+
Avoid this pattern when:
|
|
168
|
+
|
|
169
|
+
- **Content is rarely accessed** — Prefetching content most users never see wastes bandwidth.
|
|
170
|
+
- **You have many deferred sections** — Prefetching dozens of payloads simultaneously may slow down the initial page load. Consider prefetching only the most likely targets.
|
|
171
|
+
|
|
172
|
+
## See Also
|
|
173
|
+
|
|
174
|
+
- [Optimizing RSC Payloads](/funstack-static/learn/optimizing-payloads) - Using `defer()` to split RSC payloads
|
|
175
|
+
- [defer()](/funstack-static/api/defer) - API reference with full signature and technical details
|
|
176
|
+
- [React Server Components](/funstack-static/learn/rsc) - Understanding RSC fundamentals
|