feat: add configurable rscPayloadDir option#75
Conversation
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
There was a problem hiding this comment.
Pull request overview
Adds a new rscPayloadDir option to @funstack/static to make the RSC payload output directory configurable (defaulting to fun:rsc-payload), primarily to support platforms that dislike colons in path segments (e.g. Cloudflare Workers).
Changes:
- Introduces
rscPayloadDiras a plugin option (defaulting todefaultRscPayloadDir) and exposes it viavirtual:funstack/config. - Threads
rscPayloadDirthrough RSC payload ID/path generation in both runtime (defer) and build-time emission (buildApp/rscProcessor/rscPath). - Updates docs and loosens e2e assertions to avoid hard-coding the default directory name.
Reviewed changes
Copilot reviewed 11 out of 12 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/static/src/virtual.d.ts | Adds rscPayloadDir typing to the virtual config module. |
| packages/static/src/rsc/rscModule.ts | Defines defaultRscPayloadDir and makes payload IDs directory-based. |
| packages/static/src/rsc/defer.tsx | Uses rscPayloadDir from virtual config when generating deferred payload IDs. |
| packages/static/src/plugin/index.ts | Adds rscPayloadDir option, exports it via virtual config, passes it into buildApp. |
| packages/static/src/build/rscProcessor.ts | Requires rscPayloadDir to hash/emit deferred component payload IDs consistently. |
| packages/static/src/build/rscPath.ts | Requires rscPayloadDir to compute final payload paths. |
| packages/static/src/build/buildApp.ts | Threads rscPayloadDir into component processing and payload file emission. |
| packages/static/e2e/tests/multi-entry.spec.ts | Relaxes assertions around payload directory naming. |
| packages/static/e2e/tests/hydration.spec.ts | Relaxes request filtering to catch payload fetches without hard-coded dir. |
| packages/static/e2e/tests/build.spec.ts | Relaxes HTML/path assertions and payload path regex. |
| packages/docs/src/pages/learn/HowItWorks.mdx | Mentions that the payload directory name is configurable. |
| packages/docs/src/pages/api/FunstackStatic.mdx | Documents the new rscPayloadDir option with an example. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| /** | ||
| * Processes RSC components by replacing temporary UUIDs with content-based hashes. | ||
| * | ||
| * @param deferRegistryIterator - Iterator yielding components with { id, data } | ||
| * @param appRscStream - The main RSC stream | ||
| * @param context - Optional context for logging warnings | ||
| */ | ||
| export async function processRscComponents( | ||
| deferRegistryIterator: AsyncIterable<RawComponent>, | ||
| appRscStream: ReadableStream, | ||
| rscPayloadDir: string, | ||
| context?: { warn: (message: string) => void }, |
There was a problem hiding this comment.
rscPayloadDir is now a required parameter but the function docstring’s @param list wasn’t updated to describe it. Please add an entry explaining what the directory is used for (and any expected format constraints) so the signature and docs stay in sync.
| const { | ||
| publicOutDir = "dist/public", | ||
| ssr = false, | ||
| clientInit, | ||
| rscPayloadDir = defaultRscPayloadDir, | ||
| } = options; |
There was a problem hiding this comment.
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.
| /** | ||
| * 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}`; | ||
| } |
There was a problem hiding this comment.
The comment above getPayloadIDFor still talks about “Add prefix to raw ID”, but the implementation now builds ${rscPayloadDir}/${rawId} (directory + raw ID). Updating the wording would make it clearer what is actually being prefixed and why.
| @@ -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(); | |||
There was a problem hiding this comment.
These assertions were loosened to match any funstack__/...*.txt path, which can hide regressions (e.g., payloads being written to an unexpected subdirectory, or unrelated .txt files under funstack__/ satisfying the regex). Consider extracting the expected payload path from the preload/config and asserting it includes the configured rscPayloadDir, and add a dedicated e2e case that sets a non-default rscPayloadDir and verifies the emitted paths use it.
| @@ -24,7 +24,7 @@ test.describe("Multi-entry build output", () => { | |||
| expect(html).toContain("<!DOCTYPE html>"); | |||
| expect(html).toContain("<html"); | |||
| expect(html).toContain("__FUNSTACK_APP_ENTRY__"); | |||
| expect(html).toContain("funstack__/fun:rsc-payload/"); | |||
| expect(html).toContain("funstack__/"); | |||
| }); | |||
|
|
|||
| test("each page has its own RSC payload", async ({ request }) => { | |||
| @@ -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/); | |||
|
|
|||
There was a problem hiding this comment.
The updated checks only assert that some .txt under funstack__/ exists. That makes the test less specific and doesn’t verify the new rscPayloadDir option works (or that the default directory is still used when unset). Consider adding a scenario that configures rscPayloadDir in the multi-entry fixture and then assert both pages reference /funstack__/{rscPayloadDir}/...txt (and that those files are fetchable).
| 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); | ||
| } | ||
| }); |
There was a problem hiding this comment.
This request filter now matches any .txt request under funstack__/, which may overcount unrelated files and doesn’t actually prove the app fetched the main RSC payload path referenced by the HTML. To keep the test robust with configurable rscPayloadDir, consider extracting the expected payload URL from the document (preload link / manifest) and assert that exact path was requested.
- 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Add a new
rscPayloadDirplugin 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