Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions packages/docs/src/pages/api/FunstackStatic.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,25 @@ Sentry.init({

**Note:** Errors in the client init module will propagate normally and prevent the app from rendering.

### rscPayloadDir (optional)

**Type:** `string`
**Default:** `"fun:rsc-payload"`

Directory name used for RSC payload files in the build output. The final file paths follow the pattern `/funstack__/{rscPayloadDir}/{hash}.txt`.

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.

**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.

```typescript
funstackStatic({
root: "./src/root.tsx",
app: "./src/App.tsx",
rscPayloadDir: "fun-rsc-payload", // Avoid colons for Cloudflare Workers
});
```

## Full Example

### Single-Entry
Expand Down
2 changes: 1 addition & 1 deletion packages/docs/src/pages/learn/HowItWorks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ dist/public
└── index.html
```

The RSC payload files under `funstack__` are loaded by the client-side code to bootstrap the application with server-rendered content.
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.

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).

Expand Down
21 changes: 17 additions & 4 deletions packages/static/src/build/buildApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { EntryBuildResult } from "../rsc/entry";
export async function buildApp(
builder: ViteBuilder,
context: MinimalPluginContextWithoutEnvironment,
options: { rscPayloadDir: string },
) {
const { config } = builder;
// import server entry
Expand Down Expand Up @@ -50,12 +51,20 @@ export async function buildApp(
const { components, idMapping } = await processRscComponents(
deferRegistry.loadAll(),
dummyStream,
options.rscPayloadDir,
context,
);

// Write each entry's HTML and RSC payload
for (const result of entries) {
await buildSingleEntry(result, idMapping, baseDir, base, context);
await buildSingleEntry(
result,
idMapping,
baseDir,
base,
options.rscPayloadDir,
context,
);
}

// Write all deferred component payloads
Expand Down Expand Up @@ -94,6 +103,7 @@ async function buildSingleEntry(
idMapping: Map<string, string>,
baseDir: string,
base: string,
rscPayloadDir: string,
context: MinimalPluginContextWithoutEnvironment,
) {
const { path: entryPath, html, appRsc } = result;
Expand All @@ -109,8 +119,8 @@ async function buildSingleEntry(
const mainPayloadHash = await computeContentHash(appRscContent);
const mainPayloadPath =
base === ""
? getRscPayloadPath(mainPayloadHash)
: base + getRscPayloadPath(mainPayloadHash);
? getRscPayloadPath(mainPayloadHash, rscPayloadDir)
: base + getRscPayloadPath(mainPayloadHash, rscPayloadDir);

// Replace placeholder with final hashed path
const finalHtmlContent = htmlContent.replaceAll(
Expand All @@ -127,7 +137,10 @@ async function buildSingleEntry(

// Write RSC payload with hashed filename
await writeFileNormal(
path.join(baseDir, getRscPayloadPath(mainPayloadHash).replace(/^\//, "")),
path.join(
baseDir,
getRscPayloadPath(mainPayloadHash, rscPayloadDir).replace(/^\//, ""),
),
appRscContent,
context,
);
Expand Down
7 changes: 5 additions & 2 deletions packages/static/src/build/rscPath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ export const rscPayloadPlaceholder = "__FUNSTACK_RSC_PAYLOAD_PATH__";
/**
* Generate final path from content hash (reuses same folder as deferred payloads)
*/
export function getRscPayloadPath(contentHash: string): string {
return getModulePathFor(getPayloadIDFor(contentHash));
export function getRscPayloadPath(
contentHash: string,
rscPayloadDir: string,
): string {
return getModulePathFor(getPayloadIDFor(contentHash, rscPayloadDir));
}
4 changes: 3 additions & 1 deletion packages/static/src/build/rscProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,13 @@ interface RawComponent {
*
* @param deferRegistryIterator - Iterator yielding components with { id, data }
* @param appRscStream - The main RSC stream
* @param rscPayloadDir - Directory name used as a prefix for RSC payload IDs (e.g. "fun:rsc-payload")
* @param context - Optional context for logging warnings
*/
export async function processRscComponents(
deferRegistryIterator: AsyncIterable<RawComponent>,
appRscStream: ReadableStream,
rscPayloadDir: string,
context?: { warn: (message: string) => void },
): Promise<ProcessResult> {
// Step 1: Collect all components from deferRegistry
Expand Down Expand Up @@ -95,7 +97,7 @@ export async function processRscComponents(

// Compute content hash for this component
const contentHash = await computeContentHash(content);
const finalId = getPayloadIDFor(contentHash);
const finalId = getPayloadIDFor(contentHash, rscPayloadDir);

// Create mapping
idMapping.set(tempId, finalId);
Expand Down
42 changes: 39 additions & 3 deletions packages/static/src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Plugin } from "vite";
import rsc from "@vitejs/plugin-rsc";
import { buildApp } from "../build/buildApp";
import { serverPlugin } from "./server";
import { defaultRscPayloadDir } from "../rsc/rscModule";

interface FunstackStaticBaseOptions {
/**
Expand All @@ -25,6 +26,20 @@ interface FunstackStaticBaseOptions {
* The module is imported for its side effects only (no exports needed).
*/
clientInit?: string;
/**
* Directory name used for RSC payload files in the build output.
* The final path will be `/funstack__/{rscPayloadDir}/{hash}.txt`.
*
* Change this if your hosting platform has issues with the default
* directory name (e.g. Cloudflare Workers redirects URLs containing colons).
*
* The value is used as a marker for string replacement during the build
* process, so it should be unique enough that it does not appear in your
* application's source code.
*
* @default "fun:rsc-payload"
*/
rscPayloadDir?: string;
}

interface SingleEntryOptions {
Expand Down Expand Up @@ -58,7 +73,25 @@ export type FunstackStaticOptions = FunstackStaticBaseOptions &
export default function funstackStatic(
options: FunstackStaticOptions,
): (Plugin | Plugin[])[] {
const { publicOutDir = "dist/public", ssr = false, clientInit } = options;
const {
publicOutDir = "dist/public",
ssr = false,
clientInit,
rscPayloadDir = defaultRscPayloadDir,
} = options;
Comment on lines +76 to +81
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rscPayloadDir is interpolated into module IDs and later used to form filesystem paths via getModulePathFor(...) + path.join(...). Since it’s now user-configurable, values containing path separators, leading slashes, or .. segments can escape the intended funstack__/ folder or even write outside baseDir. Please validate/normalize this option (e.g., require a non-empty single path segment with no / or .., and trim leading/trailing slashes) before using it.

Copilot uses AI. Check for mistakes.

// Validate rscPayloadDir to prevent path traversal or invalid segments
if (
!rscPayloadDir ||
rscPayloadDir.includes("/") ||
rscPayloadDir.includes("\\") ||
rscPayloadDir === ".." ||
rscPayloadDir === "."
) {
throw new Error(
`[funstack] Invalid rscPayloadDir: "${rscPayloadDir}". Must be a non-empty single path segment without slashes.`,
);
}

let resolvedEntriesModule: string = "__uninitialized__";
let resolvedClientInitEntry: string | undefined;
Expand Down Expand Up @@ -166,7 +199,10 @@ export default function funstackStatic(
].join("\n");
}
if (id === "\0virtual:funstack/config") {
return `export const ssr = ${JSON.stringify(ssr)};`;
return [
`export const ssr = ${JSON.stringify(ssr)};`,
`export const rscPayloadDir = ${JSON.stringify(rscPayloadDir)};`,
].join("\n");
}
if (id === "\0virtual:funstack/client-init") {
if (resolvedClientInitEntry) {
Expand All @@ -179,7 +215,7 @@ export default function funstackStatic(
{
name: "@funstack/static:build",
async buildApp(builder) {
await buildApp(builder, this);
await buildApp(builder, this, { rscPayloadDir });
},
},
];
Expand Down
3 changes: 2 additions & 1 deletion packages/static/src/rsc/defer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { renderToReadableStream } from "@vitejs/plugin-rsc/react/rsc";
import { DeferredComponent } from "#rsc-client";
import { drainStream } from "../util/drainStream";
import { getPayloadIDFor } from "./rscModule";
import { rscPayloadDir } from "virtual:funstack/config";

export interface DeferEntry {
state: DeferEntryState;
Expand Down Expand Up @@ -184,7 +185,7 @@ export function defer(
const rawId = sanitizedName
? `${sanitizedName}-${crypto.randomUUID()}`
: crypto.randomUUID();
const id = getPayloadIDFor(rawId);
const id = getPayloadIDFor(rawId, rscPayloadDir);
deferRegistry.register(element, id, name);

return <DeferredComponent moduleID={id} />;
Expand Down
15 changes: 9 additions & 6 deletions packages/static/src/rsc/rscModule.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
/**
* ID is prefixed with this string to form module path.
* Default directory name for RSC payload files.
*/
const rscPayloadIDPrefix = "fun:rsc-payload/";
export const defaultRscPayloadDir = "fun:rsc-payload";

/**
* Add prefix to raw ID to form payload ID so that the ID is
* distinguishable from other possible IDs.
* Combines the RSC payload directory with a raw ID to form a
* namespaced payload ID (e.g. "fun:rsc-payload/abc123").
*/
export function getPayloadIDFor(rawId: string): string {
return `${rscPayloadIDPrefix}${rawId}`;
export function getPayloadIDFor(
rawId: string,
rscPayloadDir: string = defaultRscPayloadDir,
): string {
return `${rscPayloadDir}/${rawId}`;
}

const rscModulePathPrefix = "/funstack__/";
Expand Down
1 change: 1 addition & 0 deletions packages/static/src/virtual.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ declare module "virtual:funstack/entries" {
}
declare module "virtual:funstack/config" {
export const ssr: boolean;
export const rscPayloadDir: string;
}
declare module "virtual:funstack/client-init" {}