@funstack/static 1.1.0 → 1.1.2

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.
@@ -8,7 +8,7 @@ import { findReferencedIds, topologicalSort } from "./dependencyGraph.mjs";
8
8
  *
9
9
  * @param deferRegistryIterator - Iterator yielding components with { id, data }
10
10
  * @param appRscStream - The main RSC stream
11
- * @param rscPayloadDir - Directory name used as a prefix for RSC payload IDs (e.g. "fun:rsc-payload")
11
+ * @param rscPayloadDir - Directory name used as a prefix for RSC payload IDs (e.g. "fun__rsc-payload")
12
12
  * @param context - Optional context for logging warnings
13
13
  */
14
14
  async function processRscComponents(deferRegistryIterator, appRscStream, rscPayloadDir, context) {
@@ -1 +1 @@
1
- {"version":3,"file":"rscProcessor.mjs","names":[],"sources":["../../src/build/rscProcessor.ts"],"sourcesContent":["import { drainStream } from \"../util/drainStream\";\nimport { getPayloadIDFor } from \"../rsc/rscModule\";\nimport { computeContentHash } from \"./contentHash\";\nimport { findReferencedIds, topologicalSort } from \"./dependencyGraph\";\n\nexport interface ProcessedComponent {\n finalId: string;\n finalContent: string;\n name?: string;\n}\n\nexport interface ProcessResult {\n components: ProcessedComponent[];\n appRscContent: string;\n idMapping: Map<string, string>;\n}\n\ninterface RawComponent {\n id: string;\n data: string;\n name?: string;\n}\n\n/**\n * Processes RSC components by replacing temporary UUIDs with content-based hashes.\n *\n * @param deferRegistryIterator - Iterator yielding components with { id, data }\n * @param appRscStream - The main RSC stream\n * @param rscPayloadDir - Directory name used as a prefix for RSC payload IDs (e.g. \"fun:rsc-payload\")\n * @param context - Optional context for logging warnings\n */\nexport async function processRscComponents(\n deferRegistryIterator: AsyncIterable<RawComponent>,\n appRscStream: ReadableStream,\n rscPayloadDir: string,\n context?: { warn: (message: string) => void },\n): Promise<ProcessResult> {\n // Step 1: Collect all components from deferRegistry\n const components = new Map<string, string>();\n const componentNames = new Map<string, string | undefined>();\n for await (const { id, data, name } of deferRegistryIterator) {\n components.set(id, data);\n componentNames.set(id, name);\n }\n\n // Step 2: Drain appRsc stream to string\n let appRscContent = await drainStream(appRscStream);\n\n // If no components, return early\n if (components.size === 0) {\n return {\n components: [],\n appRscContent,\n idMapping: new Map(),\n };\n }\n\n const allIds = new Set(components.keys());\n\n // Step 3: Build dependency graph\n // For each component, find which other component IDs appear in its content\n const dependencies = new Map<string, Set<string>>();\n for (const [id, content] of components) {\n const otherIds = new Set(allIds);\n otherIds.delete(id); // Don't include self-references\n const refs = findReferencedIds(content, otherIds);\n dependencies.set(id, refs);\n }\n\n // Step 4: Topologically sort components\n const { sorted, inCycle } = topologicalSort(dependencies);\n\n // Step 5: Handle cycles - warn and keep original temp IDs\n const idMapping = new Map<string, string>();\n\n if (inCycle.length > 0) {\n context?.warn(\n `[funstack] Warning: ${inCycle.length} RSC component(s) are in dependency cycles and will keep unstable IDs: ${inCycle.join(\", \")}`,\n );\n for (const id of inCycle) {\n idMapping.set(id, id); // Map to itself (keep original ID)\n }\n }\n\n // Step 6: Process sorted components in order\n const processedComponents: ProcessedComponent[] = [];\n\n for (const tempId of sorted) {\n let content = components.get(tempId)!;\n\n // Replace all already-finalized temp IDs with their hash-based IDs\n for (const [oldId, newId] of idMapping) {\n if (oldId !== newId) {\n content = content.replaceAll(oldId, newId);\n }\n }\n\n // Compute content hash for this component\n const contentHash = await computeContentHash(content);\n const finalId = getPayloadIDFor(contentHash, rscPayloadDir);\n\n // Create mapping\n idMapping.set(tempId, finalId);\n\n processedComponents.push({\n finalId,\n finalContent: content,\n name: componentNames.get(tempId),\n });\n }\n\n // Add cycle members to processed components (with original IDs)\n for (const tempId of inCycle) {\n let content = components.get(tempId)!;\n\n // Replace finalized IDs in cycle member content\n for (const [oldId, newId] of idMapping) {\n if (oldId !== newId) {\n content = content.replaceAll(oldId, newId);\n }\n }\n\n processedComponents.push({\n finalId: tempId, // Keep original temp ID\n finalContent: content,\n name: componentNames.get(tempId),\n });\n }\n\n // Step 7: Process appRsc - replace all temp IDs with final IDs\n for (const [oldId, newId] of idMapping) {\n if (oldId !== newId) {\n appRscContent = appRscContent.replaceAll(oldId, newId);\n }\n }\n\n return {\n components: processedComponents,\n appRscContent,\n idMapping,\n };\n}\n"],"mappings":";;;;;;;;;;;;;AA+BA,eAAsB,qBACpB,uBACA,cACA,eACA,SACwB;CAExB,MAAM,6BAAa,IAAI,KAAqB;CAC5C,MAAM,iCAAiB,IAAI,KAAiC;AAC5D,YAAW,MAAM,EAAE,IAAI,MAAM,UAAU,uBAAuB;AAC5D,aAAW,IAAI,IAAI,KAAK;AACxB,iBAAe,IAAI,IAAI,KAAK;;CAI9B,IAAI,gBAAgB,MAAM,YAAY,aAAa;AAGnD,KAAI,WAAW,SAAS,EACtB,QAAO;EACL,YAAY,EAAE;EACd;EACA,2BAAW,IAAI,KAAK;EACrB;CAGH,MAAM,SAAS,IAAI,IAAI,WAAW,MAAM,CAAC;CAIzC,MAAM,+BAAe,IAAI,KAA0B;AACnD,MAAK,MAAM,CAAC,IAAI,YAAY,YAAY;EACtC,MAAM,WAAW,IAAI,IAAI,OAAO;AAChC,WAAS,OAAO,GAAG;EACnB,MAAM,OAAO,kBAAkB,SAAS,SAAS;AACjD,eAAa,IAAI,IAAI,KAAK;;CAI5B,MAAM,EAAE,QAAQ,YAAY,gBAAgB,aAAa;CAGzD,MAAM,4BAAY,IAAI,KAAqB;AAE3C,KAAI,QAAQ,SAAS,GAAG;AACtB,WAAS,KACP,uBAAuB,QAAQ,OAAO,yEAAyE,QAAQ,KAAK,KAAK,GAClI;AACD,OAAK,MAAM,MAAM,QACf,WAAU,IAAI,IAAI,GAAG;;CAKzB,MAAM,sBAA4C,EAAE;AAEpD,MAAK,MAAM,UAAU,QAAQ;EAC3B,IAAI,UAAU,WAAW,IAAI,OAAO;AAGpC,OAAK,MAAM,CAAC,OAAO,UAAU,UAC3B,KAAI,UAAU,MACZ,WAAU,QAAQ,WAAW,OAAO,MAAM;EAM9C,MAAM,UAAU,gBADI,MAAM,mBAAmB,QAAQ,EACR,cAAc;AAG3D,YAAU,IAAI,QAAQ,QAAQ;AAE9B,sBAAoB,KAAK;GACvB;GACA,cAAc;GACd,MAAM,eAAe,IAAI,OAAO;GACjC,CAAC;;AAIJ,MAAK,MAAM,UAAU,SAAS;EAC5B,IAAI,UAAU,WAAW,IAAI,OAAO;AAGpC,OAAK,MAAM,CAAC,OAAO,UAAU,UAC3B,KAAI,UAAU,MACZ,WAAU,QAAQ,WAAW,OAAO,MAAM;AAI9C,sBAAoB,KAAK;GACvB,SAAS;GACT,cAAc;GACd,MAAM,eAAe,IAAI,OAAO;GACjC,CAAC;;AAIJ,MAAK,MAAM,CAAC,OAAO,UAAU,UAC3B,KAAI,UAAU,MACZ,iBAAgB,cAAc,WAAW,OAAO,MAAM;AAI1D,QAAO;EACL,YAAY;EACZ;EACA;EACD"}
1
+ {"version":3,"file":"rscProcessor.mjs","names":[],"sources":["../../src/build/rscProcessor.ts"],"sourcesContent":["import { drainStream } from \"../util/drainStream\";\nimport { getPayloadIDFor } from \"../rsc/rscModule\";\nimport { computeContentHash } from \"./contentHash\";\nimport { findReferencedIds, topologicalSort } from \"./dependencyGraph\";\n\nexport interface ProcessedComponent {\n finalId: string;\n finalContent: string;\n name?: string;\n}\n\nexport interface ProcessResult {\n components: ProcessedComponent[];\n appRscContent: string;\n idMapping: Map<string, string>;\n}\n\ninterface RawComponent {\n id: string;\n data: string;\n name?: string;\n}\n\n/**\n * Processes RSC components by replacing temporary UUIDs with content-based hashes.\n *\n * @param deferRegistryIterator - Iterator yielding components with { id, data }\n * @param appRscStream - The main RSC stream\n * @param rscPayloadDir - Directory name used as a prefix for RSC payload IDs (e.g. \"fun__rsc-payload\")\n * @param context - Optional context for logging warnings\n */\nexport async function processRscComponents(\n deferRegistryIterator: AsyncIterable<RawComponent>,\n appRscStream: ReadableStream,\n rscPayloadDir: string,\n context?: { warn: (message: string) => void },\n): Promise<ProcessResult> {\n // Step 1: Collect all components from deferRegistry\n const components = new Map<string, string>();\n const componentNames = new Map<string, string | undefined>();\n for await (const { id, data, name } of deferRegistryIterator) {\n components.set(id, data);\n componentNames.set(id, name);\n }\n\n // Step 2: Drain appRsc stream to string\n let appRscContent = await drainStream(appRscStream);\n\n // If no components, return early\n if (components.size === 0) {\n return {\n components: [],\n appRscContent,\n idMapping: new Map(),\n };\n }\n\n const allIds = new Set(components.keys());\n\n // Step 3: Build dependency graph\n // For each component, find which other component IDs appear in its content\n const dependencies = new Map<string, Set<string>>();\n for (const [id, content] of components) {\n const otherIds = new Set(allIds);\n otherIds.delete(id); // Don't include self-references\n const refs = findReferencedIds(content, otherIds);\n dependencies.set(id, refs);\n }\n\n // Step 4: Topologically sort components\n const { sorted, inCycle } = topologicalSort(dependencies);\n\n // Step 5: Handle cycles - warn and keep original temp IDs\n const idMapping = new Map<string, string>();\n\n if (inCycle.length > 0) {\n context?.warn(\n `[funstack] Warning: ${inCycle.length} RSC component(s) are in dependency cycles and will keep unstable IDs: ${inCycle.join(\", \")}`,\n );\n for (const id of inCycle) {\n idMapping.set(id, id); // Map to itself (keep original ID)\n }\n }\n\n // Step 6: Process sorted components in order\n const processedComponents: ProcessedComponent[] = [];\n\n for (const tempId of sorted) {\n let content = components.get(tempId)!;\n\n // Replace all already-finalized temp IDs with their hash-based IDs\n for (const [oldId, newId] of idMapping) {\n if (oldId !== newId) {\n content = content.replaceAll(oldId, newId);\n }\n }\n\n // Compute content hash for this component\n const contentHash = await computeContentHash(content);\n const finalId = getPayloadIDFor(contentHash, rscPayloadDir);\n\n // Create mapping\n idMapping.set(tempId, finalId);\n\n processedComponents.push({\n finalId,\n finalContent: content,\n name: componentNames.get(tempId),\n });\n }\n\n // Add cycle members to processed components (with original IDs)\n for (const tempId of inCycle) {\n let content = components.get(tempId)!;\n\n // Replace finalized IDs in cycle member content\n for (const [oldId, newId] of idMapping) {\n if (oldId !== newId) {\n content = content.replaceAll(oldId, newId);\n }\n }\n\n processedComponents.push({\n finalId: tempId, // Keep original temp ID\n finalContent: content,\n name: componentNames.get(tempId),\n });\n }\n\n // Step 7: Process appRsc - replace all temp IDs with final IDs\n for (const [oldId, newId] of idMapping) {\n if (oldId !== newId) {\n appRscContent = appRscContent.replaceAll(oldId, newId);\n }\n }\n\n return {\n components: processedComponents,\n appRscContent,\n idMapping,\n };\n}\n"],"mappings":";;;;;;;;;;;;;AA+BA,eAAsB,qBACpB,uBACA,cACA,eACA,SACwB;CAExB,MAAM,6BAAa,IAAI,KAAqB;CAC5C,MAAM,iCAAiB,IAAI,KAAiC;AAC5D,YAAW,MAAM,EAAE,IAAI,MAAM,UAAU,uBAAuB;AAC5D,aAAW,IAAI,IAAI,KAAK;AACxB,iBAAe,IAAI,IAAI,KAAK;;CAI9B,IAAI,gBAAgB,MAAM,YAAY,aAAa;AAGnD,KAAI,WAAW,SAAS,EACtB,QAAO;EACL,YAAY,EAAE;EACd;EACA,2BAAW,IAAI,KAAK;EACrB;CAGH,MAAM,SAAS,IAAI,IAAI,WAAW,MAAM,CAAC;CAIzC,MAAM,+BAAe,IAAI,KAA0B;AACnD,MAAK,MAAM,CAAC,IAAI,YAAY,YAAY;EACtC,MAAM,WAAW,IAAI,IAAI,OAAO;AAChC,WAAS,OAAO,GAAG;EACnB,MAAM,OAAO,kBAAkB,SAAS,SAAS;AACjD,eAAa,IAAI,IAAI,KAAK;;CAI5B,MAAM,EAAE,QAAQ,YAAY,gBAAgB,aAAa;CAGzD,MAAM,4BAAY,IAAI,KAAqB;AAE3C,KAAI,QAAQ,SAAS,GAAG;AACtB,WAAS,KACP,uBAAuB,QAAQ,OAAO,yEAAyE,QAAQ,KAAK,KAAK,GAClI;AACD,OAAK,MAAM,MAAM,QACf,WAAU,IAAI,IAAI,GAAG;;CAKzB,MAAM,sBAA4C,EAAE;AAEpD,MAAK,MAAM,UAAU,QAAQ;EAC3B,IAAI,UAAU,WAAW,IAAI,OAAO;AAGpC,OAAK,MAAM,CAAC,OAAO,UAAU,UAC3B,KAAI,UAAU,MACZ,WAAU,QAAQ,WAAW,OAAO,MAAM;EAM9C,MAAM,UAAU,gBADI,MAAM,mBAAmB,QAAQ,EACR,cAAc;AAG3D,YAAU,IAAI,QAAQ,QAAQ;AAE9B,sBAAoB,KAAK;GACvB;GACA,cAAc;GACd,MAAM,eAAe,IAAI,OAAO;GACjC,CAAC;;AAIJ,MAAK,MAAM,UAAU,SAAS;EAC5B,IAAI,UAAU,WAAW,IAAI,OAAO;AAGpC,OAAK,MAAM,CAAC,OAAO,UAAU,UAC3B,KAAI,UAAU,MACZ,WAAU,QAAQ,WAAW,OAAO,MAAM;AAI9C,sBAAoB,KAAK;GACvB,SAAS;GACT,cAAc;GACd,MAAM,eAAe,IAAI,OAAO;GACjC,CAAC;;AAIJ,MAAK,MAAM,CAAC,OAAO,UAAU,UAC3B,KAAI,UAAU,MACZ,iBAAgB,cAAc,WAAW,OAAO,MAAM;AAI1D,QAAO;EACL,YAAY;EACZ;EACA;EACD"}
@@ -202,7 +202,7 @@ dist/public/
202
202
  ├── blog/
203
203
  │ └── post-1.html
204
204
  ├── funstack__/
205
- │ └── fun:rsc-payload/
205
+ │ └── fun__rsc-payload/
206
206
  │ ├── a1b2c3d4.txt # RSC payload for index.html
207
207
  │ ├── e5f6g7h8.txt # RSC payload for about.html
208
208
  │ ├── i9j0k1l2.txt # RSC payload for blog/post-1.html
@@ -65,7 +65,7 @@ interface DeferOptions {
65
65
  ```
66
66
 
67
67
  - **name:** An optional identifier to help with debugging. When provided:
68
- - In development mode, the name is included in the RSC payload file name (e.g., `/funstack__/fun:rsc-payload/HomePage-b5698be72eea3c37`)
68
+ - In development mode, the name is included in the RSC payload file name (e.g., `/funstack__/fun__rsc-payload/HomePage-b5698be72eea3c37`)
69
69
  - In production mode, the name is logged when the payload file is emitted
70
70
 
71
71
  ### Returns
@@ -105,6 +105,6 @@ Using the `name` option makes it easier to identify which deferred component cor
105
105
 
106
106
  By default, FUNSTACK Static puts the entire app (`<App />`) into one RSC payload (`/funstack__/index.txt`). The client fetches this payload to render your SPA.
107
107
 
108
- When you use `defer(<Component />)`, FUNSTACK Static creates **additional RSC payloads** for the rendering result of the element. This results in an additional emit of RSC payload files like `/funstack__/fun:rsc-payload/b5698be72eea3c37`. If you provide a `name` option, the file name will include it (e.g., `/funstack__/fun:rsc-payload/HomePage-b5698be72eea3c37`).
108
+ When you use `defer(<Component />)`, FUNSTACK Static creates **additional RSC payloads** for the rendering result of the element. This results in an additional emit of RSC payload files like `/funstack__/fun__rsc-payload/b5698be72eea3c37`. If you provide a `name` option, the file name will include it (e.g., `/funstack__/fun__rsc-payload/HomePage-b5698be72eea3c37`).
109
109
 
110
- In the main RSC payload, the `defer` call is replaced with a client component `<DeferredComponent moduleId="fun:rsc-payload/b5698be72eea3c37" />`. This component is responsible for fetching the additional RSC payload from client and renders it when it's ready.
110
+ In the main RSC payload, the `defer` call is replaced with a client component `<DeferredComponent moduleId="fun__rsc-payload/b5698be72eea3c37" />`. This component is responsible for fetching the additional RSC payload from client and renders it when it's ready.
@@ -229,19 +229,19 @@ Sentry.init({
229
229
  ### rscPayloadDir (optional)
230
230
 
231
231
  **Type:** `string`
232
- **Default:** `"fun:rsc-payload"`
232
+ **Default:** `"fun__rsc-payload"`
233
233
 
234
234
  Directory name used for RSC payload files in the build output. The final file paths follow the pattern `/funstack__/{rscPayloadDir}/{hash}.txt`.
235
235
 
236
- Change this if your hosting platform has issues with the default directory name. For example, Cloudflare Workers redirects URLs containing colons to percent-encoded equivalents, adding an extra round trip.
236
+ Change this if your hosting platform has issues with the default directory name.
237
237
 
238
- **Important:** The value is used as a marker for string replacement during the build process. Choose a value that is unique enough that it does not appear in your application's source code. The default value `"fun:rsc-payload"` is designed to be unlikely to collide with user code.
238
+ **Important:** The value is used as a marker for string replacement during the build process. Choose a value that is unique enough that it does not appear in your application's source code. The default value `"fun__rsc-payload"` is designed to be unlikely to collide with user code.
239
239
 
240
240
  ```typescript
241
241
  funstackStatic({
242
242
  root: "./src/root.tsx",
243
243
  app: "./src/App.tsx",
244
- rscPayloadDir: "fun-rsc-payload", // Avoid colons for Cloudflare Workers
244
+ rscPayloadDir: "my-custom-rsc-payload",
245
245
  });
246
246
  ```
247
247
 
@@ -22,6 +22,7 @@ A Vite plugin for building static sites with React Server Components.
22
22
  ### Learn
23
23
 
24
24
  - [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.
25
+ - [File-System Routing](./learn/FileSystemRouting.md) - FUNSTACK Static does not include a built-in file-system router, but you can implement one in userland using Vite's `import.meta.glob` and a router library like [FUNSTACK Router](https://github.com/uhyo/funstack-router).
25
26
  - [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.
26
27
  - [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.
27
28
  - [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.
@@ -0,0 +1,95 @@
1
+ # File-System Routing
2
+
3
+ FUNSTACK Static does not include a built-in file-system router, but you can implement one in userland using Vite's `import.meta.glob` and a router library like [FUNSTACK Router](https://github.com/uhyo/funstack-router).
4
+
5
+ ## How It Works
6
+
7
+ The idea is to use `import.meta.glob` to discover page components from a `pages/` directory at compile time, then convert the file paths into route definitions.
8
+
9
+ ```tsx
10
+ import { route, type RouteDefinition } from "@funstack/router/server";
11
+
12
+ const pageModules = import.meta.glob<{ default: React.ComponentType }>(
13
+ "./pages/**/*.tsx",
14
+ { eager: true },
15
+ );
16
+
17
+ function filePathToUrlPath(filePath: string): string {
18
+ let urlPath = filePath.replace(/^\.\/pages/, "").replace(/\.tsx$/, "");
19
+ if (urlPath.endsWith("/index")) {
20
+ urlPath = urlPath.slice(0, -"/index".length);
21
+ }
22
+ return urlPath || "/";
23
+ }
24
+
25
+ export const routes: RouteDefinition[] = Object.entries(pageModules).map(
26
+ ([filePath, module]) => {
27
+ const Page = module.default;
28
+ return route({
29
+ path: filePathToUrlPath(filePath),
30
+ component: <Page />,
31
+ });
32
+ },
33
+ );
34
+ ```
35
+
36
+ With this setup, files in the `pages/` directory are automatically mapped to routes:
37
+
38
+ | File | Route |
39
+ | ---------------------- | -------- |
40
+ | `pages/index.tsx` | `/` |
41
+ | `pages/about.tsx` | `/about` |
42
+ | `pages/blog/index.tsx` | `/blog` |
43
+
44
+ ## Why import.meta.glob?
45
+
46
+ Using `import.meta.glob` has two key advantages:
47
+
48
+ - **Automatic discovery** — you don't need to manually register each page. Just add a new `.tsx` file and it becomes a route.
49
+ - **Hot module replacement** — Vite tracks the glob pattern, so adding or removing page files in development triggers an automatic update without a server restart.
50
+
51
+ ## Static Generation
52
+
53
+ To generate static HTML for each route, derive [entry definitions](/api/entry-definition) from the route list:
54
+
55
+ ```tsx
56
+ import type { EntryDefinition } from "@funstack/static/entries";
57
+ import type { RouteDefinition } from "@funstack/router/server";
58
+
59
+ function collectPaths(routes: RouteDefinition[]): string[] {
60
+ const paths: string[] = [];
61
+ for (const route of routes) {
62
+ if (route.children) {
63
+ paths.push(...collectPaths(route.children));
64
+ } else if (route.path !== undefined && route.path !== "*") {
65
+ paths.push(route.path);
66
+ }
67
+ }
68
+ return paths;
69
+ }
70
+
71
+ function pathToEntryPath(path: string): string {
72
+ if (path === "/") return "index.html";
73
+ return `${path.slice(1)}.html`;
74
+ }
75
+
76
+ export default function getEntries(): EntryDefinition[] {
77
+ return collectPaths(routes).map((pathname) => ({
78
+ path: pathToEntryPath(pathname),
79
+ root: () => import("./root"),
80
+ app: <App ssrPath={pathname} />,
81
+ }));
82
+ }
83
+ ```
84
+
85
+ This produces one HTML file per route at build time.
86
+
87
+ ## Full Example
88
+
89
+ For a complete working example, see the [`example-fs-routing`](https://github.com/uhyo/funstack-static/tree/master/packages/example-fs-routing) package in the FUNSTACK Static repository.
90
+
91
+ ## See Also
92
+
93
+ - [Multiple Entrypoints](/advanced/multiple-entrypoints) - Generating multiple HTML pages from a single project
94
+ - [EntryDefinition](/api/entry-definition) - API reference for entry definitions
95
+ - [How It Works](/learn/how-it-works) - Overall FUNSTACK Static architecture
@@ -45,12 +45,12 @@ dist/public
45
45
  │ ├── root-DvE5ENz2.css
46
46
  │ └── rsc-D0fjt5Ie.js
47
47
  ├── funstack__
48
- │ └── fun:rsc-payload
48
+ │ └── fun__rsc-payload
49
49
  │ └── db1923b9b6507ab4.txt
50
50
  └── index.html
51
51
  ```
52
52
 
53
- The RSC payload files under `funstack__` are loaded by the client-side code to bootstrap the application with server-rendered content. The `fun:rsc-payload` directory name is [configurable](/api/funstack-static#rscpayloaddir-optional) via the `rscPayloadDir` option.
53
+ The RSC payload files under `funstack__` are loaded by the client-side code to bootstrap the application with server-rendered content. The `fun__rsc-payload` directory name is [configurable](/api/funstack-static#rscpayloaddir-optional) via the `rscPayloadDir` option.
54
54
 
55
55
  This can been seen as an **optimized version of traditional client-only SPAs**, where the entire application is bundled into JavaScript files. By using RSC, some of the rendering work is offloaded to the build time, resulting in smaller JavaScript bundles combined with RSC payloads that require less client-side processing (parsing is easier, no JavaScript execution needed).
56
56
 
@@ -8,7 +8,7 @@ Without any optimization, your entire application is rendered into one RSC paylo
8
8
 
9
9
  ```
10
10
  dist/public/funstack__/
11
- └── fun:rsc-payload/
11
+ └── fun__rsc-payload/
12
12
  └── b62ec6668fd49300.txt ← Contains everything
13
13
  ```
14
14
 
@@ -75,7 +75,7 @@ After building with route-level `defer()`, your output looks like this:
75
75
 
76
76
  ```
77
77
  dist/public/funstack__/
78
- └── fun:rsc-payload/
78
+ └── fun__rsc-payload/
79
79
  ├── a3f2b1c9d8e7f6a5.txt ← Home page
80
80
  ├── b5698be72eea3c37.txt ← About page
81
81
  ├── b62ec6668fd49300.txt ← Main app shell
@@ -3,7 +3,8 @@
3
3
  * Get the entry point module of the RSC environment.
4
4
  */
5
5
  async function getRSCEntryPoint(environment) {
6
- const rscInput = environment.config.build.rollupOptions?.input;
6
+ const buildConfig = environment.config.build;
7
+ const rscInput = (buildConfig.rolldownOptions ?? buildConfig.rollupOptions)?.input;
7
8
  const source = rscInput !== void 0 && typeof rscInput !== "string" && !Array.isArray(rscInput) ? rscInput.index : void 0;
8
9
  if (source === void 0) throw new Error("Cannot determine RSC entry point");
9
10
  const resolved = await environment.pluginContainer.resolveId(source);
@@ -1 +1 @@
1
- {"version":3,"file":"getRSCEntryPoint.mjs","names":[],"sources":["../../src/plugin/getRSCEntryPoint.ts"],"sourcesContent":["import type { RunnableDevEnvironment } from \"vite\";\n\n/**\n * Get the entry point module of the RSC environment.\n */\nexport async function getRSCEntryPoint(environment: RunnableDevEnvironment) {\n const rscInput = environment.config.build.rollupOptions?.input;\n const source =\n rscInput !== undefined &&\n typeof rscInput !== \"string\" &&\n !Array.isArray(rscInput)\n ? rscInput.index\n : undefined;\n if (source === undefined) {\n throw new Error(\"Cannot determine RSC entry point\");\n }\n const resolved = await environment.pluginContainer.resolveId(source);\n if (!resolved) {\n throw new Error(`Cannot resolve RSC entry: ${source}`);\n }\n const rscEntry = await environment.runner.import<\n typeof import(\"../rsc/entry\")\n >(resolved.id);\n return rscEntry;\n}\n"],"mappings":";;;;AAKA,eAAsB,iBAAiB,aAAqC;CAC1E,MAAM,WAAW,YAAY,OAAO,MAAM,eAAe;CACzD,MAAM,SACJ,aAAa,KAAA,KACb,OAAO,aAAa,YACpB,CAAC,MAAM,QAAQ,SAAS,GACpB,SAAS,QACT,KAAA;AACN,KAAI,WAAW,KAAA,EACb,OAAM,IAAI,MAAM,mCAAmC;CAErD,MAAM,WAAW,MAAM,YAAY,gBAAgB,UAAU,OAAO;AACpE,KAAI,CAAC,SACH,OAAM,IAAI,MAAM,6BAA6B,SAAS;AAKxD,QAHiB,MAAM,YAAY,OAAO,OAExC,SAAS,GAAG"}
1
+ {"version":3,"file":"getRSCEntryPoint.mjs","names":[],"sources":["../../src/plugin/getRSCEntryPoint.ts"],"sourcesContent":["import type { RunnableDevEnvironment } from \"vite\";\n\n/**\n * Get the entry point module of the RSC environment.\n */\nexport async function getRSCEntryPoint(environment: RunnableDevEnvironment) {\n // Vite 8 renamed rollupOptions to rolldownOptions; support both for Vite 7 compat\n const buildConfig = environment.config.build;\n // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Vite 7 compat\n const rscInput = (\n buildConfig.rolldownOptions ?? (buildConfig as any).rollupOptions\n )?.input;\n const source =\n rscInput !== undefined &&\n typeof rscInput !== \"string\" &&\n !Array.isArray(rscInput)\n ? rscInput.index\n : undefined;\n if (source === undefined) {\n throw new Error(\"Cannot determine RSC entry point\");\n }\n const resolved = await environment.pluginContainer.resolveId(source);\n if (!resolved) {\n throw new Error(`Cannot resolve RSC entry: ${source}`);\n }\n const rscEntry = await environment.runner.import<\n typeof import(\"../rsc/entry\")\n >(resolved.id);\n return rscEntry;\n}\n"],"mappings":";;;;AAKA,eAAsB,iBAAiB,aAAqC;CAE1E,MAAM,cAAc,YAAY,OAAO;CAEvC,MAAM,YACJ,YAAY,mBAAoB,YAAoB,gBACnD;CACH,MAAM,SACJ,aAAa,KAAA,KACb,OAAO,aAAa,YACpB,CAAC,MAAM,QAAQ,SAAS,GACpB,SAAS,QACT,KAAA;AACN,KAAI,WAAW,KAAA,EACb,OAAM,IAAI,MAAM,mCAAmC;CAErD,MAAM,WAAW,MAAM,YAAY,gBAAgB,UAAU,OAAO;AACpE,KAAI,CAAC,SACH,OAAM,IAAI,MAAM,6BAA6B,SAAS;AAKxD,QAHiB,MAAM,YAAY,OAAO,OAExC,SAAS,GAAG"}
@@ -27,13 +27,13 @@ interface FunstackStaticBaseOptions {
27
27
  * The final path will be `/funstack__/{rscPayloadDir}/{hash}.txt`.
28
28
  *
29
29
  * Change this if your hosting platform has issues with the default
30
- * directory name (e.g. Cloudflare Workers redirects URLs containing colons).
30
+ * directory name.
31
31
  *
32
32
  * The value is used as a marker for string replacement during the build
33
33
  * process, so it should be unique enough that it does not appear in your
34
34
  * application's source code.
35
35
  *
36
- * @default "fun:rsc-payload"
36
+ * @default "fun__rsc-payload"
37
37
  */
38
38
  rscPayloadDir?: string;
39
39
  }
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../../src/plugin/index.ts"],"mappings":";;;UAOU,yBAAA;;AANyB;;;;EAYjC,YAAA;EAQA;;;;;AAoBa;;EApBb,GAAA;EAuB0B;;;;;EAjB1B,UAAA;EAgCQ;;;;;;;;;AAUV;;;;EA5BE,aAAA;AAAA;AAAA,UAGQ,kBAAA;EA0BoC;;;;;EApB5C,IAAA;EAsBsB;;;;EAjBtB,GAAA;EACA,OAAA;AAAA;AAAA,UAGQ,sBAAA;EACR,IAAA;EACA,GAAA;EAaE;;;;EARF,OAAA;AAAA;AAAA,KAGU,qBAAA,GAAwB,yBAAA,IACjC,kBAAA,GAAqB,sBAAA;AAAA,iBAEA,cAAA,CACtB,OAAA,EAAS,qBAAA,IACP,MAAA,GAAS,MAAA"}
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../../src/plugin/index.ts"],"mappings":";;;UAOU,yBAAA;;AANwC;;;;EAYhD,YAAA;EAQA;;;;;AAoBa;;EApBb,GAAA;EAuB0B;;;;;EAjB1B,UAAA;EAgCQ;;;;;;;;;AAUV;;;;EA5BE,aAAA;AAAA;AAAA,UAGQ,kBAAA;EA0BoC;;;;;EApB5C,IAAA;EAsBsB;;;;EAjBtB,GAAA;EACA,OAAA;AAAA;AAAA,UAGQ,sBAAA;EACR,IAAA;EACA,GAAA;EAaE;;;;EARF,OAAA;AAAA;AAAA,KAGU,qBAAA,GAAwB,yBAAA,IACjC,kBAAA,GAAqB,sBAAA;AAAA,iBAEA,cAAA,CACtB,OAAA,EAAS,qBAAA,IACP,MAAA,GAAS,MAAA"}
@@ -2,6 +2,7 @@ import { defaultRscPayloadDir } from "../rsc/rscModule.mjs";
2
2
  import { buildApp } from "../build/buildApp.mjs";
3
3
  import { serverPlugin } from "./server.mjs";
4
4
  import path from "node:path";
5
+ import { normalizePath } from "vite";
5
6
  import rsc from "@vitejs/plugin-rsc";
6
7
  //#region src/plugin/index.ts
7
8
  function funstackStatic(options) {
@@ -29,16 +30,16 @@ function funstackStatic(options) {
29
30
  {
30
31
  name: "@funstack/static:config",
31
32
  configResolved(config) {
32
- if (isMultiEntry) resolvedEntriesModule = path.resolve(config.root, options.entries);
33
+ if (isMultiEntry) resolvedEntriesModule = normalizePath(path.resolve(config.root, options.entries));
33
34
  else {
34
- const resolvedRoot = path.resolve(config.root, options.root);
35
- const resolvedApp = path.resolve(config.root, options.app);
35
+ const resolvedRoot = normalizePath(path.resolve(config.root, options.root));
36
+ const resolvedApp = normalizePath(path.resolve(config.root, options.app));
36
37
  resolvedEntriesModule = JSON.stringify({
37
38
  root: resolvedRoot,
38
39
  app: resolvedApp
39
40
  });
40
41
  }
41
- if (clientInit) resolvedClientInitEntry = path.resolve(config.root, clientInit);
42
+ if (clientInit) resolvedClientInitEntry = normalizePath(path.resolve(config.root, clientInit));
42
43
  },
43
44
  configEnvironment(_name, config) {
44
45
  if (!config.optimizeDeps) config.optimizeDeps = {};
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":[],"sources":["../../src/plugin/index.ts"],"sourcesContent":["import path from \"node:path\";\nimport type { Plugin } from \"vite\";\nimport rsc from \"@vitejs/plugin-rsc\";\nimport { buildApp } from \"../build/buildApp\";\nimport { serverPlugin } from \"./server\";\nimport { defaultRscPayloadDir } from \"../rsc/rscModule\";\n\ninterface FunstackStaticBaseOptions {\n /**\n * Output directory for build.\n *\n * @default dist/public\n */\n publicOutDir?: string;\n /**\n * Enable server-side rendering of the App component.\n * When false, only the Root shell is SSR'd and the App renders client-side.\n * When true, both Root and App are SSR'd and the client hydrates.\n *\n * @default false\n */\n ssr?: boolean;\n /**\n * Path to a module that runs on the client side before React hydration.\n * Use this for client-side instrumentation like Sentry, analytics, or feature flags.\n * The module is imported for its side effects only (no exports needed).\n */\n clientInit?: string;\n /**\n * Directory name used for RSC payload files in the build output.\n * The final path will be `/funstack__/{rscPayloadDir}/{hash}.txt`.\n *\n * Change this if your hosting platform has issues with the default\n * directory name (e.g. Cloudflare Workers redirects URLs containing colons).\n *\n * The value is used as a marker for string replacement during the build\n * process, so it should be unique enough that it does not appear in your\n * application's source code.\n *\n * @default \"fun:rsc-payload\"\n */\n rscPayloadDir?: string;\n}\n\ninterface SingleEntryOptions {\n /**\n * Root component of the page.\n * The file should `export default` a React component that renders the whole page.\n * (`<html>...</html>`).\n */\n root: string;\n /**\n * Entry point of your application.\n * The file should `export default` a React component that renders the application content.\n */\n app: string;\n entries?: never;\n}\n\ninterface MultipleEntriesOptions {\n root?: never;\n app?: never;\n /**\n * Path to a module that exports a function returning entry definitions.\n * Mutually exclusive with `root`+`app`.\n */\n entries: string;\n}\n\nexport type FunstackStaticOptions = FunstackStaticBaseOptions &\n (SingleEntryOptions | MultipleEntriesOptions);\n\nexport default function funstackStatic(\n options: FunstackStaticOptions,\n): (Plugin | Plugin[])[] {\n const {\n publicOutDir = \"dist/public\",\n ssr = false,\n clientInit,\n rscPayloadDir = defaultRscPayloadDir,\n } = options;\n\n // Validate rscPayloadDir to prevent path traversal or invalid segments\n if (\n !rscPayloadDir ||\n rscPayloadDir.includes(\"/\") ||\n rscPayloadDir.includes(\"\\\\\") ||\n rscPayloadDir === \"..\" ||\n rscPayloadDir === \".\"\n ) {\n throw new Error(\n `[funstack] Invalid rscPayloadDir: \"${rscPayloadDir}\". Must be a non-empty single path segment without slashes.`,\n );\n }\n\n let resolvedEntriesModule: string = \"__uninitialized__\";\n let resolvedClientInitEntry: string | undefined;\n\n // Determine whether user specified entries or root+app\n const isMultiEntry = \"entries\" in options && options.entries !== undefined;\n\n return [\n {\n name: \"@funstack/static:config-pre\",\n // Placed early because the rsc plugin sets the outDir to the default value\n config(config) {\n return {\n environments: {\n client: {\n build: {\n outDir:\n config.environments?.client?.build?.outDir ?? publicOutDir,\n },\n },\n },\n };\n },\n },\n serverPlugin(),\n rsc({\n entries: {\n rsc: \"@funstack/static/entries/rsc\",\n ssr: \"@funstack/static/entries/ssr\",\n client: \"@funstack/static/entries/client\",\n },\n serverHandler: false,\n }),\n {\n name: \"@funstack/static:config\",\n configResolved(config) {\n if (isMultiEntry) {\n resolvedEntriesModule = path.resolve(config.root, options.entries);\n } else {\n // For single-entry, we store both resolved paths to generate a\n // synthetic entries module in the virtual module loader.\n const resolvedRoot = path.resolve(config.root, options.root);\n const resolvedApp = path.resolve(config.root, options.app);\n // Encode as JSON for safe embedding in generated code\n resolvedEntriesModule = JSON.stringify({\n root: resolvedRoot,\n app: resolvedApp,\n });\n }\n if (clientInit) {\n resolvedClientInitEntry = path.resolve(config.root, clientInit);\n }\n },\n configEnvironment(_name, config) {\n if (!config.optimizeDeps) {\n config.optimizeDeps = {};\n }\n // Needed for properly bundling @vitejs/plugin-rsc for browser.\n // See: https://github.com/vitejs/vite-plugin-react/tree/79bf57cc8b9c77e33970ec2e876bd6d2f1568d5d/packages/plugin-rsc#using-vitejsplugin-rsc-as-a-framework-packages-dependencies\n if (config.optimizeDeps.include) {\n config.optimizeDeps.include = config.optimizeDeps.include.map(\n (entry) => {\n if (entry.startsWith(\"@vitejs/plugin-rsc\")) {\n entry = `@funstack/static > ${entry}`;\n }\n return entry;\n },\n );\n }\n if (!config.optimizeDeps.exclude) {\n config.optimizeDeps.exclude = [];\n }\n // Since code includes imports to virtual modules, we need to exclude\n // us from Optimize Deps.\n config.optimizeDeps.exclude.push(\"@funstack/static\");\n },\n },\n {\n name: \"@funstack/static:virtual-entry\",\n resolveId(id) {\n if (id === \"virtual:funstack/entries\") {\n return \"\\0virtual:funstack/entries\";\n }\n if (id === \"virtual:funstack/config\") {\n return \"\\0virtual:funstack/config\";\n }\n if (id === \"virtual:funstack/client-init\") {\n return \"\\0virtual:funstack/client-init\";\n }\n },\n load(id) {\n if (id === \"\\0virtual:funstack/entries\") {\n if (isMultiEntry) {\n // Re-export the user's entries module\n return `export { default } from \"${resolvedEntriesModule}\";`;\n }\n // Synthesize a single-entry array from root+app\n const { root, app } = JSON.parse(resolvedEntriesModule);\n return [\n `import Root from \"${root}\";`,\n `import App from \"${app}\";`,\n `export default function getEntries() {`,\n ` return [{ path: \"index.html\", root: { default: Root }, app: { default: App } }];`,\n `}`,\n ].join(\"\\n\");\n }\n if (id === \"\\0virtual:funstack/config\") {\n return [\n `export const ssr = ${JSON.stringify(ssr)};`,\n `export const rscPayloadDir = ${JSON.stringify(rscPayloadDir)};`,\n ].join(\"\\n\");\n }\n if (id === \"\\0virtual:funstack/client-init\") {\n if (resolvedClientInitEntry) {\n return `import \"${resolvedClientInitEntry}\";`;\n }\n return \"\";\n }\n },\n },\n {\n name: \"@funstack/static:build\",\n async buildApp(builder) {\n await buildApp(builder, this, { rscPayloadDir });\n },\n },\n ];\n}\n"],"mappings":";;;;;;AAwEA,SAAwB,eACtB,SACuB;CACvB,MAAM,EACJ,eAAe,eACf,MAAM,OACN,YACA,gBAAgB,yBACd;AAGJ,KACE,CAAC,iBACD,cAAc,SAAS,IAAI,IAC3B,cAAc,SAAS,KAAK,IAC5B,kBAAkB,QAClB,kBAAkB,IAElB,OAAM,IAAI,MACR,sCAAsC,cAAc,6DACrD;CAGH,IAAI,wBAAgC;CACpC,IAAI;CAGJ,MAAM,eAAe,aAAa,WAAW,QAAQ,YAAY,KAAA;AAEjE,QAAO;EACL;GACE,MAAM;GAEN,OAAO,QAAQ;AACb,WAAO,EACL,cAAc,EACZ,QAAQ,EACN,OAAO,EACL,QACE,OAAO,cAAc,QAAQ,OAAO,UAAU,cACjD,EACF,EACF,EACF;;GAEJ;EACD,cAAc;EACd,IAAI;GACF,SAAS;IACP,KAAK;IACL,KAAK;IACL,QAAQ;IACT;GACD,eAAe;GAChB,CAAC;EACF;GACE,MAAM;GACN,eAAe,QAAQ;AACrB,QAAI,aACF,yBAAwB,KAAK,QAAQ,OAAO,MAAM,QAAQ,QAAQ;SAC7D;KAGL,MAAM,eAAe,KAAK,QAAQ,OAAO,MAAM,QAAQ,KAAK;KAC5D,MAAM,cAAc,KAAK,QAAQ,OAAO,MAAM,QAAQ,IAAI;AAE1D,6BAAwB,KAAK,UAAU;MACrC,MAAM;MACN,KAAK;MACN,CAAC;;AAEJ,QAAI,WACF,2BAA0B,KAAK,QAAQ,OAAO,MAAM,WAAW;;GAGnE,kBAAkB,OAAO,QAAQ;AAC/B,QAAI,CAAC,OAAO,aACV,QAAO,eAAe,EAAE;AAI1B,QAAI,OAAO,aAAa,QACtB,QAAO,aAAa,UAAU,OAAO,aAAa,QAAQ,KACvD,UAAU;AACT,SAAI,MAAM,WAAW,qBAAqB,CACxC,SAAQ,sBAAsB;AAEhC,YAAO;MAEV;AAEH,QAAI,CAAC,OAAO,aAAa,QACvB,QAAO,aAAa,UAAU,EAAE;AAIlC,WAAO,aAAa,QAAQ,KAAK,mBAAmB;;GAEvD;EACD;GACE,MAAM;GACN,UAAU,IAAI;AACZ,QAAI,OAAO,2BACT,QAAO;AAET,QAAI,OAAO,0BACT,QAAO;AAET,QAAI,OAAO,+BACT,QAAO;;GAGX,KAAK,IAAI;AACP,QAAI,OAAO,8BAA8B;AACvC,SAAI,aAEF,QAAO,4BAA4B,sBAAsB;KAG3D,MAAM,EAAE,MAAM,QAAQ,KAAK,MAAM,sBAAsB;AACvD,YAAO;MACL,qBAAqB,KAAK;MAC1B,oBAAoB,IAAI;MACxB;MACA;MACA;MACD,CAAC,KAAK,KAAK;;AAEd,QAAI,OAAO,4BACT,QAAO,CACL,sBAAsB,KAAK,UAAU,IAAI,CAAC,IAC1C,gCAAgC,KAAK,UAAU,cAAc,CAAC,GAC/D,CAAC,KAAK,KAAK;AAEd,QAAI,OAAO,kCAAkC;AAC3C,SAAI,wBACF,QAAO,WAAW,wBAAwB;AAE5C,YAAO;;;GAGZ;EACD;GACE,MAAM;GACN,MAAM,SAAS,SAAS;AACtB,UAAM,SAAS,SAAS,MAAM,EAAE,eAAe,CAAC;;GAEnD;EACF"}
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../../src/plugin/index.ts"],"sourcesContent":["import path from \"node:path\";\nimport { normalizePath, type Plugin } from \"vite\";\nimport rsc from \"@vitejs/plugin-rsc\";\nimport { buildApp } from \"../build/buildApp\";\nimport { serverPlugin } from \"./server\";\nimport { defaultRscPayloadDir } from \"../rsc/rscModule\";\n\ninterface FunstackStaticBaseOptions {\n /**\n * Output directory for build.\n *\n * @default dist/public\n */\n publicOutDir?: string;\n /**\n * Enable server-side rendering of the App component.\n * When false, only the Root shell is SSR'd and the App renders client-side.\n * When true, both Root and App are SSR'd and the client hydrates.\n *\n * @default false\n */\n ssr?: boolean;\n /**\n * Path to a module that runs on the client side before React hydration.\n * Use this for client-side instrumentation like Sentry, analytics, or feature flags.\n * The module is imported for its side effects only (no exports needed).\n */\n clientInit?: string;\n /**\n * Directory name used for RSC payload files in the build output.\n * The final path will be `/funstack__/{rscPayloadDir}/{hash}.txt`.\n *\n * Change this if your hosting platform has issues with the default\n * directory name.\n *\n * The value is used as a marker for string replacement during the build\n * process, so it should be unique enough that it does not appear in your\n * application's source code.\n *\n * @default \"fun__rsc-payload\"\n */\n rscPayloadDir?: string;\n}\n\ninterface SingleEntryOptions {\n /**\n * Root component of the page.\n * The file should `export default` a React component that renders the whole page.\n * (`<html>...</html>`).\n */\n root: string;\n /**\n * Entry point of your application.\n * The file should `export default` a React component that renders the application content.\n */\n app: string;\n entries?: never;\n}\n\ninterface MultipleEntriesOptions {\n root?: never;\n app?: never;\n /**\n * Path to a module that exports a function returning entry definitions.\n * Mutually exclusive with `root`+`app`.\n */\n entries: string;\n}\n\nexport type FunstackStaticOptions = FunstackStaticBaseOptions &\n (SingleEntryOptions | MultipleEntriesOptions);\n\nexport default function funstackStatic(\n options: FunstackStaticOptions,\n): (Plugin | Plugin[])[] {\n const {\n publicOutDir = \"dist/public\",\n ssr = false,\n clientInit,\n rscPayloadDir = defaultRscPayloadDir,\n } = options;\n\n // Validate rscPayloadDir to prevent path traversal or invalid segments\n if (\n !rscPayloadDir ||\n rscPayloadDir.includes(\"/\") ||\n rscPayloadDir.includes(\"\\\\\") ||\n rscPayloadDir === \"..\" ||\n rscPayloadDir === \".\"\n ) {\n throw new Error(\n `[funstack] Invalid rscPayloadDir: \"${rscPayloadDir}\". Must be a non-empty single path segment without slashes.`,\n );\n }\n\n let resolvedEntriesModule: string = \"__uninitialized__\";\n let resolvedClientInitEntry: string | undefined;\n\n // Determine whether user specified entries or root+app\n const isMultiEntry = \"entries\" in options && options.entries !== undefined;\n\n return [\n {\n name: \"@funstack/static:config-pre\",\n // Placed early because the rsc plugin sets the outDir to the default value\n config(config) {\n return {\n environments: {\n client: {\n build: {\n outDir:\n config.environments?.client?.build?.outDir ?? publicOutDir,\n },\n },\n },\n };\n },\n },\n serverPlugin(),\n rsc({\n entries: {\n rsc: \"@funstack/static/entries/rsc\",\n ssr: \"@funstack/static/entries/ssr\",\n client: \"@funstack/static/entries/client\",\n },\n serverHandler: false,\n }),\n {\n name: \"@funstack/static:config\",\n configResolved(config) {\n if (isMultiEntry) {\n resolvedEntriesModule = normalizePath(\n path.resolve(config.root, options.entries),\n );\n } else {\n // For single-entry, we store both resolved paths to generate a\n // synthetic entries module in the virtual module loader.\n const resolvedRoot = normalizePath(\n path.resolve(config.root, options.root),\n );\n const resolvedApp = normalizePath(\n path.resolve(config.root, options.app),\n );\n // Encode as JSON for safe embedding in generated code\n resolvedEntriesModule = JSON.stringify({\n root: resolvedRoot,\n app: resolvedApp,\n });\n }\n if (clientInit) {\n resolvedClientInitEntry = normalizePath(\n path.resolve(config.root, clientInit),\n );\n }\n },\n configEnvironment(_name, config) {\n if (!config.optimizeDeps) {\n config.optimizeDeps = {};\n }\n // Needed for properly bundling @vitejs/plugin-rsc for browser.\n // See: https://github.com/vitejs/vite-plugin-react/tree/79bf57cc8b9c77e33970ec2e876bd6d2f1568d5d/packages/plugin-rsc#using-vitejsplugin-rsc-as-a-framework-packages-dependencies\n if (config.optimizeDeps.include) {\n config.optimizeDeps.include = config.optimizeDeps.include.map(\n (entry) => {\n if (entry.startsWith(\"@vitejs/plugin-rsc\")) {\n entry = `@funstack/static > ${entry}`;\n }\n return entry;\n },\n );\n }\n if (!config.optimizeDeps.exclude) {\n config.optimizeDeps.exclude = [];\n }\n // Since code includes imports to virtual modules, we need to exclude\n // us from Optimize Deps.\n config.optimizeDeps.exclude.push(\"@funstack/static\");\n },\n },\n {\n name: \"@funstack/static:virtual-entry\",\n resolveId(id) {\n if (id === \"virtual:funstack/entries\") {\n return \"\\0virtual:funstack/entries\";\n }\n if (id === \"virtual:funstack/config\") {\n return \"\\0virtual:funstack/config\";\n }\n if (id === \"virtual:funstack/client-init\") {\n return \"\\0virtual:funstack/client-init\";\n }\n },\n load(id) {\n if (id === \"\\0virtual:funstack/entries\") {\n if (isMultiEntry) {\n // Re-export the user's entries module\n return `export { default } from \"${resolvedEntriesModule}\";`;\n }\n // Synthesize a single-entry array from root+app\n const { root, app } = JSON.parse(resolvedEntriesModule);\n return [\n `import Root from \"${root}\";`,\n `import App from \"${app}\";`,\n `export default function getEntries() {`,\n ` return [{ path: \"index.html\", root: { default: Root }, app: { default: App } }];`,\n `}`,\n ].join(\"\\n\");\n }\n if (id === \"\\0virtual:funstack/config\") {\n return [\n `export const ssr = ${JSON.stringify(ssr)};`,\n `export const rscPayloadDir = ${JSON.stringify(rscPayloadDir)};`,\n ].join(\"\\n\");\n }\n if (id === \"\\0virtual:funstack/client-init\") {\n if (resolvedClientInitEntry) {\n return `import \"${resolvedClientInitEntry}\";`;\n }\n return \"\";\n }\n },\n },\n {\n name: \"@funstack/static:build\",\n async buildApp(builder) {\n await buildApp(builder, this, { rscPayloadDir });\n },\n },\n ];\n}\n"],"mappings":";;;;;;;AAwEA,SAAwB,eACtB,SACuB;CACvB,MAAM,EACJ,eAAe,eACf,MAAM,OACN,YACA,gBAAgB,yBACd;AAGJ,KACE,CAAC,iBACD,cAAc,SAAS,IAAI,IAC3B,cAAc,SAAS,KAAK,IAC5B,kBAAkB,QAClB,kBAAkB,IAElB,OAAM,IAAI,MACR,sCAAsC,cAAc,6DACrD;CAGH,IAAI,wBAAgC;CACpC,IAAI;CAGJ,MAAM,eAAe,aAAa,WAAW,QAAQ,YAAY,KAAA;AAEjE,QAAO;EACL;GACE,MAAM;GAEN,OAAO,QAAQ;AACb,WAAO,EACL,cAAc,EACZ,QAAQ,EACN,OAAO,EACL,QACE,OAAO,cAAc,QAAQ,OAAO,UAAU,cACjD,EACF,EACF,EACF;;GAEJ;EACD,cAAc;EACd,IAAI;GACF,SAAS;IACP,KAAK;IACL,KAAK;IACL,QAAQ;IACT;GACD,eAAe;GAChB,CAAC;EACF;GACE,MAAM;GACN,eAAe,QAAQ;AACrB,QAAI,aACF,yBAAwB,cACtB,KAAK,QAAQ,OAAO,MAAM,QAAQ,QAAQ,CAC3C;SACI;KAGL,MAAM,eAAe,cACnB,KAAK,QAAQ,OAAO,MAAM,QAAQ,KAAK,CACxC;KACD,MAAM,cAAc,cAClB,KAAK,QAAQ,OAAO,MAAM,QAAQ,IAAI,CACvC;AAED,6BAAwB,KAAK,UAAU;MACrC,MAAM;MACN,KAAK;MACN,CAAC;;AAEJ,QAAI,WACF,2BAA0B,cACxB,KAAK,QAAQ,OAAO,MAAM,WAAW,CACtC;;GAGL,kBAAkB,OAAO,QAAQ;AAC/B,QAAI,CAAC,OAAO,aACV,QAAO,eAAe,EAAE;AAI1B,QAAI,OAAO,aAAa,QACtB,QAAO,aAAa,UAAU,OAAO,aAAa,QAAQ,KACvD,UAAU;AACT,SAAI,MAAM,WAAW,qBAAqB,CACxC,SAAQ,sBAAsB;AAEhC,YAAO;MAEV;AAEH,QAAI,CAAC,OAAO,aAAa,QACvB,QAAO,aAAa,UAAU,EAAE;AAIlC,WAAO,aAAa,QAAQ,KAAK,mBAAmB;;GAEvD;EACD;GACE,MAAM;GACN,UAAU,IAAI;AACZ,QAAI,OAAO,2BACT,QAAO;AAET,QAAI,OAAO,0BACT,QAAO;AAET,QAAI,OAAO,+BACT,QAAO;;GAGX,KAAK,IAAI;AACP,QAAI,OAAO,8BAA8B;AACvC,SAAI,aAEF,QAAO,4BAA4B,sBAAsB;KAG3D,MAAM,EAAE,MAAM,QAAQ,KAAK,MAAM,sBAAsB;AACvD,YAAO;MACL,qBAAqB,KAAK;MAC1B,oBAAoB,IAAI;MACxB;MACA;MACA;MACD,CAAC,KAAK,KAAK;;AAEd,QAAI,OAAO,4BACT,QAAO,CACL,sBAAsB,KAAK,UAAU,IAAI,CAAC,IAC1C,gCAAgC,KAAK,UAAU,cAAc,CAAC,GAC/D,CAAC,KAAK,KAAK;AAEd,QAAI,OAAO,kCAAkC;AAC3C,SAAI,wBACF,QAAO,WAAW,wBAAwB;AAE5C,YAAO;;;GAGZ;EACD;GACE,MAAM;GACN,MAAM,SAAS,SAAS;AACtB,UAAM,SAAS,SAAS,MAAM,EAAE,eAAe,CAAC;;GAEnD;EACF"}
@@ -1,8 +1,8 @@
1
1
  import { getRSCEntryPoint } from "./getRSCEntryPoint.mjs";
2
2
  import { urlPathToFileCandidates } from "../util/urlPath.mjs";
3
3
  import path from "node:path";
4
- import { readFile } from "node:fs/promises";
5
4
  import { isRunnableDevEnvironment } from "vite";
5
+ import { readFile } from "node:fs/promises";
6
6
  import { toNodeHandler } from "srvx/node";
7
7
  //#region src/plugin/server.ts
8
8
  const serverPlugin = () => {
@@ -2,10 +2,10 @@
2
2
  /**
3
3
  * Default directory name for RSC payload files.
4
4
  */
5
- const defaultRscPayloadDir = "fun:rsc-payload";
5
+ const defaultRscPayloadDir = "fun__rsc-payload";
6
6
  /**
7
7
  * Combines the RSC payload directory with a raw ID to form a
8
- * namespaced payload ID (e.g. "fun:rsc-payload/abc123").
8
+ * namespaced payload ID (e.g. "fun__rsc-payload/abc123").
9
9
  */
10
10
  function getPayloadIDFor(rawId, rscPayloadDir = defaultRscPayloadDir) {
11
11
  return `${rscPayloadDir}/${rawId}`;
@@ -1 +1 @@
1
- {"version":3,"file":"rscModule.mjs","names":[],"sources":["../../src/rsc/rscModule.ts"],"sourcesContent":["/**\n * Default directory name for RSC payload files.\n */\nexport const defaultRscPayloadDir = \"fun:rsc-payload\";\n\n/**\n * Combines the RSC payload directory with a raw ID to form a\n * namespaced payload ID (e.g. \"fun:rsc-payload/abc123\").\n */\nexport function getPayloadIDFor(\n rawId: string,\n rscPayloadDir: string = defaultRscPayloadDir,\n): string {\n return `${rscPayloadDir}/${rawId}`;\n}\n\nconst rscModulePathPrefix = \"/funstack__/\";\nconst rscModulePathSuffix = \".txt\";\n\nexport function getModulePathFor(id: string): string {\n return `${rscModulePathPrefix}${id}${rscModulePathSuffix}`;\n}\n\nexport function extractIDFromModulePath(\n modulePath: string,\n): string | undefined {\n if (\n !modulePath.startsWith(rscModulePathPrefix) ||\n !modulePath.endsWith(rscModulePathSuffix)\n ) {\n return undefined;\n }\n return modulePath.slice(\n rscModulePathPrefix.length,\n -rscModulePathSuffix.length,\n );\n}\n"],"mappings":";;;;AAGA,MAAa,uBAAuB;;;;;AAMpC,SAAgB,gBACd,OACA,gBAAwB,sBAChB;AACR,QAAO,GAAG,cAAc,GAAG;;AAG7B,MAAM,sBAAsB;AAC5B,MAAM,sBAAsB;AAE5B,SAAgB,iBAAiB,IAAoB;AACnD,QAAO,GAAG,sBAAsB,KAAK;;AAGvC,SAAgB,wBACd,YACoB;AACpB,KACE,CAAC,WAAW,WAAW,oBAAoB,IAC3C,CAAC,WAAW,SAAS,oBAAoB,CAEzC;AAEF,QAAO,WAAW,MAChB,IACA,GACD"}
1
+ {"version":3,"file":"rscModule.mjs","names":[],"sources":["../../src/rsc/rscModule.ts"],"sourcesContent":["/**\n * Default directory name for RSC payload files.\n */\nexport const defaultRscPayloadDir = \"fun__rsc-payload\";\n\n/**\n * Combines the RSC payload directory with a raw ID to form a\n * namespaced payload ID (e.g. \"fun__rsc-payload/abc123\").\n */\nexport function getPayloadIDFor(\n rawId: string,\n rscPayloadDir: string = defaultRscPayloadDir,\n): string {\n return `${rscPayloadDir}/${rawId}`;\n}\n\nconst rscModulePathPrefix = \"/funstack__/\";\nconst rscModulePathSuffix = \".txt\";\n\nexport function getModulePathFor(id: string): string {\n return `${rscModulePathPrefix}${id}${rscModulePathSuffix}`;\n}\n\nexport function extractIDFromModulePath(\n modulePath: string,\n): string | undefined {\n if (\n !modulePath.startsWith(rscModulePathPrefix) ||\n !modulePath.endsWith(rscModulePathSuffix)\n ) {\n return undefined;\n }\n return modulePath.slice(\n rscModulePathPrefix.length,\n -rscModulePathSuffix.length,\n );\n}\n"],"mappings":";;;;AAGA,MAAa,uBAAuB;;;;;AAMpC,SAAgB,gBACd,OACA,gBAAwB,sBAChB;AACR,QAAO,GAAG,cAAc,GAAG;;AAG7B,MAAM,sBAAsB;AAC5B,MAAM,sBAAsB;AAE5B,SAAgB,iBAAiB,IAAoB;AACnD,QAAO,GAAG,sBAAsB,KAAK;;AAGvC,SAAgB,wBACd,YACoB;AACpB,KACE,CAAC,WAAW,WAAW,oBAAoB,IAC3C,CAAC,WAAW,SAAS,oBAAoB,CAEzC;AAEF,QAAO,WAAW,MAChB,IACA,GACD"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@funstack/static",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "FUNSTACK static library",
5
5
  "type": "module",
6
6
  "repository": {
@@ -47,28 +47,28 @@
47
47
  "license": "MIT",
48
48
  "devDependencies": {
49
49
  "@playwright/test": "^1.58.2",
50
- "@types/node": "^25.3.5",
50
+ "@types/node": "^25.5.0",
51
51
  "@types/react": "^19.2.14",
52
52
  "@types/react-dom": "^19.2.3",
53
- "jsdom": "^28.1.0",
53
+ "jsdom": "^29.0.1",
54
54
  "react": "^19.2.4",
55
55
  "react-dom": "^19.2.4",
56
- "tsdown": "^0.21.0",
56
+ "tsdown": "^0.21.4",
57
57
  "typescript": "^5.9.3",
58
- "vite": "^7.3.1",
59
- "vitest": "^4.0.18"
58
+ "vite": "^8.0.0",
59
+ "vitest": "^4.1.0"
60
60
  },
61
61
  "dependencies": {
62
62
  "@funstack/skill-installer": "^1.0.0",
63
63
  "@vitejs/plugin-rsc": "^0.5.21",
64
64
  "react-error-boundary": "^6.1.1",
65
65
  "rsc-html-stream": "^0.0.7",
66
- "srvx": "^0.11.8"
66
+ "srvx": "^0.11.12"
67
67
  },
68
68
  "peerDependencies": {
69
69
  "react": "^19.2.3",
70
70
  "react-dom": "^19.2.3",
71
- "vite": "^7.3.1"
71
+ "vite": "^7.0.0 || ^8.0.0"
72
72
  },
73
73
  "scripts": {
74
74
  "build": "tsdown",