From 75e2a26d5f9123c62c6eee1166507947bd1e9e1d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Mar 2026 10:56:27 +0000 Subject: [PATCH 1/4] feat: add configurable `rscPayloadDir` option Add a new `rscPayloadDir` plugin option (default: "fun:rsc-payload") to control the directory name used for RSC payload files. This allows platforms like Cloudflare Workers to avoid the colon in the default directory name, which causes unnecessary redirects to percent-encoded URLs. https://claude.ai/code/session_01XA4ndHQWu7o59WdkECDnG9 --- packages/static/e2e/tests/build.spec.ts | 6 ++--- packages/static/e2e/tests/hydration.spec.ts | 2 +- packages/static/e2e/tests/multi-entry.spec.ts | 12 +++------ packages/static/src/build/buildApp.ts | 21 +++++++++++++--- packages/static/src/build/rscPath.ts | 7 ++++-- packages/static/src/build/rscProcessor.ts | 3 ++- packages/static/src/plugin/index.ts | 25 ++++++++++++++++--- packages/static/src/rsc/defer.tsx | 3 ++- packages/static/src/rsc/rscModule.ts | 11 +++++--- packages/static/src/virtual.d.ts | 1 + 10 files changed, 63 insertions(+), 28 deletions(-) diff --git a/packages/static/e2e/tests/build.spec.ts b/packages/static/e2e/tests/build.spec.ts index c278fc3..1b7857c 100644 --- a/packages/static/e2e/tests/build.spec.ts +++ b/packages/static/e2e/tests/build.spec.ts @@ -20,7 +20,7 @@ test.describe("Build output verification", () => { expect(html).toContain("__FUNSTACK_APP_ENTRY__"); // Verify the RSC payload is preloaded expect(html).toContain('rel="preload"'); - expect(html).toContain("funstack__/fun:rsc-payload/"); + expect(html).toContain("funstack__/"); }); test("generates RSC payload files at /funstack__/*.txt", async ({ @@ -31,9 +31,7 @@ test.describe("Build output verification", () => { const html = await indexResponse.text(); // Look for the RSC payload in preload link or FUNSTACK config - const rscPayloadMatch = html.match( - /funstack__\/fun:rsc-payload\/[^"'\s]+\.txt/, - ); + const rscPayloadMatch = html.match(/funstack__\/[^"'\s]+\.txt/); expect(rscPayloadMatch).not.toBeNull(); const rscPayloadPath = "/" + rscPayloadMatch![0]; diff --git a/packages/static/e2e/tests/hydration.spec.ts b/packages/static/e2e/tests/hydration.spec.ts index 99c301d..5ebdc24 100644 --- a/packages/static/e2e/tests/hydration.spec.ts +++ b/packages/static/e2e/tests/hydration.spec.ts @@ -62,7 +62,7 @@ test.describe("Client-side hydration", () => { page.on("request", (request) => { const url = request.url(); - if (url.includes("funstack__/fun:rsc-payload/") && url.endsWith(".txt")) { + if (url.includes("funstack__/") && url.endsWith(".txt")) { rscRequests.push(url); } }); diff --git a/packages/static/e2e/tests/multi-entry.spec.ts b/packages/static/e2e/tests/multi-entry.spec.ts index 9b9078b..95e06b8 100644 --- a/packages/static/e2e/tests/multi-entry.spec.ts +++ b/packages/static/e2e/tests/multi-entry.spec.ts @@ -11,7 +11,7 @@ test.describe("Multi-entry build output", () => { expect(html).toContain(""); expect(html).toContain(" { expect(html).toContain(""); expect(html).toContain(" { @@ -35,12 +35,8 @@ test.describe("Multi-entry build output", () => { const aboutHtml = await aboutResponse.text(); // Both pages should reference RSC payloads - const homePayloadMatch = homeHtml.match( - /funstack__\/fun:rsc-payload\/[^"'\s]+\.txt/, - ); - const aboutPayloadMatch = aboutHtml.match( - /funstack__\/fun:rsc-payload\/[^"'\s]+\.txt/, - ); + const homePayloadMatch = homeHtml.match(/funstack__\/[^"'\s]+\.txt/); + const aboutPayloadMatch = aboutHtml.match(/funstack__\/[^"'\s]+\.txt/); expect(homePayloadMatch).not.toBeNull(); expect(aboutPayloadMatch).not.toBeNull(); 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..9e97bb2 100644 --- a/packages/static/src/build/rscProcessor.ts +++ b/packages/static/src/build/rscProcessor.ts @@ -31,6 +31,7 @@ interface RawComponent { 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 +96,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..f93c9fe 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,16 @@ 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). + * + * @default "fun:rsc-payload" + */ + rscPayloadDir?: string; } interface SingleEntryOptions { @@ -58,7 +69,12 @@ 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; let resolvedEntriesModule: string = "__uninitialized__"; let resolvedClientInitEntry: string | undefined; @@ -166,7 +182,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 +198,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..1983621 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. */ -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" {} From e3730159aa7166196b45d52436fdb1aa3fd81333 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Mar 2026 10:57:34 +0000 Subject: [PATCH 2/4] docs: document `rscPayloadDir` option https://claude.ai/code/session_01XA4ndHQWu7o59WdkECDnG9 --- packages/docs/src/pages/api/FunstackStatic.mdx | 17 +++++++++++++++++ packages/docs/src/pages/learn/HowItWorks.mdx | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/docs/src/pages/api/FunstackStatic.mdx b/packages/docs/src/pages/api/FunstackStatic.mdx index a5d7e96..2d715ef 100644 --- a/packages/docs/src/pages/api/FunstackStatic.mdx +++ b/packages/docs/src/pages/api/FunstackStatic.mdx @@ -226,6 +226,23 @@ 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. + +```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). From f3f6e028e97319e7e5cbfed0a11abbdafffbd486 Mon Sep 17 00:00:00 2001 From: uhyo Date: Mon, 9 Mar 2026 22:36:18 +0900 Subject: [PATCH 3/4] fix: address PR review comments for rscPayloadDir - Validate rscPayloadDir to prevent path traversal (reject slashes, .., empty) - Add @param docstring for rscPayloadDir in rscProcessor - Update comment in rscModule to reflect directory-based payload IDs - Restore specific fun:rsc-payload/ assertions in e2e tests Co-Authored-By: Claude Opus 4.6 --- packages/static/e2e/tests/build.spec.ts | 6 ++++-- packages/static/e2e/tests/hydration.spec.ts | 2 +- packages/static/e2e/tests/multi-entry.spec.ts | 12 ++++++++---- packages/static/src/build/rscProcessor.ts | 1 + packages/static/src/plugin/index.ts | 13 +++++++++++++ packages/static/src/rsc/rscModule.ts | 4 ++-- 6 files changed, 29 insertions(+), 9 deletions(-) diff --git a/packages/static/e2e/tests/build.spec.ts b/packages/static/e2e/tests/build.spec.ts index 1b7857c..c278fc3 100644 --- a/packages/static/e2e/tests/build.spec.ts +++ b/packages/static/e2e/tests/build.spec.ts @@ -20,7 +20,7 @@ test.describe("Build output verification", () => { expect(html).toContain("__FUNSTACK_APP_ENTRY__"); // Verify the RSC payload is preloaded expect(html).toContain('rel="preload"'); - expect(html).toContain("funstack__/"); + expect(html).toContain("funstack__/fun:rsc-payload/"); }); test("generates RSC payload files at /funstack__/*.txt", async ({ @@ -31,7 +31,9 @@ test.describe("Build output verification", () => { const html = await indexResponse.text(); // Look for the RSC payload in preload link or FUNSTACK config - const rscPayloadMatch = html.match(/funstack__\/[^"'\s]+\.txt/); + const rscPayloadMatch = html.match( + /funstack__\/fun:rsc-payload\/[^"'\s]+\.txt/, + ); expect(rscPayloadMatch).not.toBeNull(); const rscPayloadPath = "/" + rscPayloadMatch![0]; diff --git a/packages/static/e2e/tests/hydration.spec.ts b/packages/static/e2e/tests/hydration.spec.ts index 5ebdc24..99c301d 100644 --- a/packages/static/e2e/tests/hydration.spec.ts +++ b/packages/static/e2e/tests/hydration.spec.ts @@ -62,7 +62,7 @@ test.describe("Client-side hydration", () => { page.on("request", (request) => { const url = request.url(); - if (url.includes("funstack__/") && url.endsWith(".txt")) { + if (url.includes("funstack__/fun:rsc-payload/") && url.endsWith(".txt")) { rscRequests.push(url); } }); diff --git a/packages/static/e2e/tests/multi-entry.spec.ts b/packages/static/e2e/tests/multi-entry.spec.ts index 95e06b8..9b9078b 100644 --- a/packages/static/e2e/tests/multi-entry.spec.ts +++ b/packages/static/e2e/tests/multi-entry.spec.ts @@ -11,7 +11,7 @@ test.describe("Multi-entry build output", () => { expect(html).toContain(""); expect(html).toContain(" { expect(html).toContain(""); expect(html).toContain(" { @@ -35,8 +35,12 @@ test.describe("Multi-entry build output", () => { const aboutHtml = await aboutResponse.text(); // Both pages should reference RSC payloads - const homePayloadMatch = homeHtml.match(/funstack__\/[^"'\s]+\.txt/); - const aboutPayloadMatch = aboutHtml.match(/funstack__\/[^"'\s]+\.txt/); + const homePayloadMatch = homeHtml.match( + /funstack__\/fun:rsc-payload\/[^"'\s]+\.txt/, + ); + const aboutPayloadMatch = aboutHtml.match( + /funstack__\/fun:rsc-payload\/[^"'\s]+\.txt/, + ); expect(homePayloadMatch).not.toBeNull(); expect(aboutPayloadMatch).not.toBeNull(); diff --git a/packages/static/src/build/rscProcessor.ts b/packages/static/src/build/rscProcessor.ts index 9e97bb2..8d363e4 100644 --- a/packages/static/src/build/rscProcessor.ts +++ b/packages/static/src/build/rscProcessor.ts @@ -26,6 +26,7 @@ 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( diff --git a/packages/static/src/plugin/index.ts b/packages/static/src/plugin/index.ts index f93c9fe..255631c 100644 --- a/packages/static/src/plugin/index.ts +++ b/packages/static/src/plugin/index.ts @@ -76,6 +76,19 @@ export default function funstackStatic( 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; diff --git a/packages/static/src/rsc/rscModule.ts b/packages/static/src/rsc/rscModule.ts index 1983621..6624126 100644 --- a/packages/static/src/rsc/rscModule.ts +++ b/packages/static/src/rsc/rscModule.ts @@ -4,8 +4,8 @@ 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, From cc996c8bb25bd7ee9fd94b00e8023dae28d3d821 Mon Sep 17 00:00:00 2001 From: uhyo Date: Mon, 9 Mar 2026 22:37:52 +0900 Subject: [PATCH 4/4] docs: note that rscPayloadDir must be unique in source code The value is used for string replacement during build, so it must not appear in the application's source code. Co-Authored-By: Claude Opus 4.6 --- packages/docs/src/pages/api/FunstackStatic.mdx | 2 ++ packages/static/src/plugin/index.ts | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/packages/docs/src/pages/api/FunstackStatic.mdx b/packages/docs/src/pages/api/FunstackStatic.mdx index 2d715ef..9a72569 100644 --- a/packages/docs/src/pages/api/FunstackStatic.mdx +++ b/packages/docs/src/pages/api/FunstackStatic.mdx @@ -235,6 +235,8 @@ Directory name used for RSC payload files in the build output. The final file pa 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", diff --git a/packages/static/src/plugin/index.ts b/packages/static/src/plugin/index.ts index 255631c..481b580 100644 --- a/packages/static/src/plugin/index.ts +++ b/packages/static/src/plugin/index.ts @@ -33,6 +33,10 @@ interface FunstackStaticBaseOptions { * 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;