diff --git a/packages/docs/src/pages/api/FunstackStatic.mdx b/packages/docs/src/pages/api/FunstackStatic.mdx index a5d7e96..9a72569 100644 --- a/packages/docs/src/pages/api/FunstackStatic.mdx +++ b/packages/docs/src/pages/api/FunstackStatic.mdx @@ -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 diff --git a/packages/docs/src/pages/learn/HowItWorks.mdx b/packages/docs/src/pages/learn/HowItWorks.mdx index c7a0f98..34c352a 100644 --- a/packages/docs/src/pages/learn/HowItWorks.mdx +++ b/packages/docs/src/pages/learn/HowItWorks.mdx @@ -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). diff --git a/packages/static/src/build/buildApp.ts b/packages/static/src/build/buildApp.ts index 3a94504..35b6a5d 100644 --- a/packages/static/src/build/buildApp.ts +++ b/packages/static/src/build/buildApp.ts @@ -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 @@ -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 @@ -94,6 +103,7 @@ async function buildSingleEntry( idMapping: Map, baseDir: string, base: string, + rscPayloadDir: string, context: MinimalPluginContextWithoutEnvironment, ) { const { path: entryPath, html, appRsc } = result; @@ -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( @@ -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, ); diff --git a/packages/static/src/build/rscPath.ts b/packages/static/src/build/rscPath.ts index 026a44d..aaa489a 100644 --- a/packages/static/src/build/rscPath.ts +++ b/packages/static/src/build/rscPath.ts @@ -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)); } diff --git a/packages/static/src/build/rscProcessor.ts b/packages/static/src/build/rscProcessor.ts index 9cf903a..8d363e4 100644 --- a/packages/static/src/build/rscProcessor.ts +++ b/packages/static/src/build/rscProcessor.ts @@ -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, appRscStream: ReadableStream, + rscPayloadDir: string, context?: { warn: (message: string) => void }, ): Promise { // Step 1: Collect all components from deferRegistry @@ -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); diff --git a/packages/static/src/plugin/index.ts b/packages/static/src/plugin/index.ts index 30c3e35..481b580 100644 --- a/packages/static/src/plugin/index.ts +++ b/packages/static/src/plugin/index.ts @@ -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 { /** @@ -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 { @@ -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; + + // 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; @@ -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) { @@ -179,7 +215,7 @@ export default function funstackStatic( { name: "@funstack/static:build", async buildApp(builder) { - await buildApp(builder, this); + await buildApp(builder, this, { rscPayloadDir }); }, }, ]; diff --git a/packages/static/src/rsc/defer.tsx b/packages/static/src/rsc/defer.tsx index e9887a4..ebbd0cd 100644 --- a/packages/static/src/rsc/defer.tsx +++ b/packages/static/src/rsc/defer.tsx @@ -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; @@ -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 ; diff --git a/packages/static/src/rsc/rscModule.ts b/packages/static/src/rsc/rscModule.ts index bae49a0..6624126 100644 --- a/packages/static/src/rsc/rscModule.ts +++ b/packages/static/src/rsc/rscModule.ts @@ -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__/"; diff --git a/packages/static/src/virtual.d.ts b/packages/static/src/virtual.d.ts index 92596dd..0f79119 100644 --- a/packages/static/src/virtual.d.ts +++ b/packages/static/src/virtual.d.ts @@ -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" {}