Skip to content

Commit 68213cc

Browse files
authored
feat: add configurable rscPayloadDir option (#75)
1 parent 944aa9b commit 68213cc

9 files changed

Lines changed: 96 additions & 18 deletions

File tree

packages/docs/src/pages/api/FunstackStatic.mdx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,25 @@ Sentry.init({
226226

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

229+
### rscPayloadDir (optional)
230+
231+
**Type:** `string`
232+
**Default:** `"fun:rsc-payload"`
233+
234+
Directory name used for RSC payload files in the build output. The final file paths follow the pattern `/funstack__/{rscPayloadDir}/{hash}.txt`.
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.
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.
239+
240+
```typescript
241+
funstackStatic({
242+
root: "./src/root.tsx",
243+
app: "./src/App.tsx",
244+
rscPayloadDir: "fun-rsc-payload", // Avoid colons for Cloudflare Workers
245+
});
246+
```
247+
229248
## Full Example
230249

231250
### Single-Entry

packages/docs/src/pages/learn/HowItWorks.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ dist/public
5050
└── index.html
5151
```
5252

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

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

packages/static/src/build/buildApp.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type { EntryBuildResult } from "../rsc/entry";
1313
export async function buildApp(
1414
builder: ViteBuilder,
1515
context: MinimalPluginContextWithoutEnvironment,
16+
options: { rscPayloadDir: string },
1617
) {
1718
const { config } = builder;
1819
// import server entry
@@ -50,12 +51,20 @@ export async function buildApp(
5051
const { components, idMapping } = await processRscComponents(
5152
deferRegistry.loadAll(),
5253
dummyStream,
54+
options.rscPayloadDir,
5355
context,
5456
);
5557

5658
// Write each entry's HTML and RSC payload
5759
for (const result of entries) {
58-
await buildSingleEntry(result, idMapping, baseDir, base, context);
60+
await buildSingleEntry(
61+
result,
62+
idMapping,
63+
baseDir,
64+
base,
65+
options.rscPayloadDir,
66+
context,
67+
);
5968
}
6069

6170
// Write all deferred component payloads
@@ -94,6 +103,7 @@ async function buildSingleEntry(
94103
idMapping: Map<string, string>,
95104
baseDir: string,
96105
base: string,
106+
rscPayloadDir: string,
97107
context: MinimalPluginContextWithoutEnvironment,
98108
) {
99109
const { path: entryPath, html, appRsc } = result;
@@ -109,8 +119,8 @@ async function buildSingleEntry(
109119
const mainPayloadHash = await computeContentHash(appRscContent);
110120
const mainPayloadPath =
111121
base === ""
112-
? getRscPayloadPath(mainPayloadHash)
113-
: base + getRscPayloadPath(mainPayloadHash);
122+
? getRscPayloadPath(mainPayloadHash, rscPayloadDir)
123+
: base + getRscPayloadPath(mainPayloadHash, rscPayloadDir);
114124

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

128138
// Write RSC payload with hashed filename
129139
await writeFileNormal(
130-
path.join(baseDir, getRscPayloadPath(mainPayloadHash).replace(/^\//, "")),
140+
path.join(
141+
baseDir,
142+
getRscPayloadPath(mainPayloadHash, rscPayloadDir).replace(/^\//, ""),
143+
),
131144
appRscContent,
132145
context,
133146
);

packages/static/src/build/rscPath.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ export const rscPayloadPlaceholder = "__FUNSTACK_RSC_PAYLOAD_PATH__";
88
/**
99
* Generate final path from content hash (reuses same folder as deferred payloads)
1010
*/
11-
export function getRscPayloadPath(contentHash: string): string {
12-
return getModulePathFor(getPayloadIDFor(contentHash));
11+
export function getRscPayloadPath(
12+
contentHash: string,
13+
rscPayloadDir: string,
14+
): string {
15+
return getModulePathFor(getPayloadIDFor(contentHash, rscPayloadDir));
1316
}

packages/static/src/build/rscProcessor.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,13 @@ interface RawComponent {
2626
*
2727
* @param deferRegistryIterator - Iterator yielding components with { id, data }
2828
* @param appRscStream - The main RSC stream
29+
* @param rscPayloadDir - Directory name used as a prefix for RSC payload IDs (e.g. "fun:rsc-payload")
2930
* @param context - Optional context for logging warnings
3031
*/
3132
export async function processRscComponents(
3233
deferRegistryIterator: AsyncIterable<RawComponent>,
3334
appRscStream: ReadableStream,
35+
rscPayloadDir: string,
3436
context?: { warn: (message: string) => void },
3537
): Promise<ProcessResult> {
3638
// Step 1: Collect all components from deferRegistry
@@ -95,7 +97,7 @@ export async function processRscComponents(
9597

9698
// Compute content hash for this component
9799
const contentHash = await computeContentHash(content);
98-
const finalId = getPayloadIDFor(contentHash);
100+
const finalId = getPayloadIDFor(contentHash, rscPayloadDir);
99101

100102
// Create mapping
101103
idMapping.set(tempId, finalId);

packages/static/src/plugin/index.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { Plugin } from "vite";
33
import rsc from "@vitejs/plugin-rsc";
44
import { buildApp } from "../build/buildApp";
55
import { serverPlugin } from "./server";
6+
import { defaultRscPayloadDir } from "../rsc/rscModule";
67

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

3045
interface SingleEntryOptions {
@@ -58,7 +73,25 @@ export type FunstackStaticOptions = FunstackStaticBaseOptions &
5873
export default function funstackStatic(
5974
options: FunstackStaticOptions,
6075
): (Plugin | Plugin[])[] {
61-
const { publicOutDir = "dist/public", ssr = false, clientInit } = options;
76+
const {
77+
publicOutDir = "dist/public",
78+
ssr = false,
79+
clientInit,
80+
rscPayloadDir = defaultRscPayloadDir,
81+
} = options;
82+
83+
// Validate rscPayloadDir to prevent path traversal or invalid segments
84+
if (
85+
!rscPayloadDir ||
86+
rscPayloadDir.includes("/") ||
87+
rscPayloadDir.includes("\\") ||
88+
rscPayloadDir === ".." ||
89+
rscPayloadDir === "."
90+
) {
91+
throw new Error(
92+
`[funstack] Invalid rscPayloadDir: "${rscPayloadDir}". Must be a non-empty single path segment without slashes.`,
93+
);
94+
}
6295

6396
let resolvedEntriesModule: string = "__uninitialized__";
6497
let resolvedClientInitEntry: string | undefined;
@@ -166,7 +199,10 @@ export default function funstackStatic(
166199
].join("\n");
167200
}
168201
if (id === "\0virtual:funstack/config") {
169-
return `export const ssr = ${JSON.stringify(ssr)};`;
202+
return [
203+
`export const ssr = ${JSON.stringify(ssr)};`,
204+
`export const rscPayloadDir = ${JSON.stringify(rscPayloadDir)};`,
205+
].join("\n");
170206
}
171207
if (id === "\0virtual:funstack/client-init") {
172208
if (resolvedClientInitEntry) {
@@ -179,7 +215,7 @@ export default function funstackStatic(
179215
{
180216
name: "@funstack/static:build",
181217
async buildApp(builder) {
182-
await buildApp(builder, this);
218+
await buildApp(builder, this, { rscPayloadDir });
183219
},
184220
},
185221
];

packages/static/src/rsc/defer.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { renderToReadableStream } from "@vitejs/plugin-rsc/react/rsc";
33
import { DeferredComponent } from "#rsc-client";
44
import { drainStream } from "../util/drainStream";
55
import { getPayloadIDFor } from "./rscModule";
6+
import { rscPayloadDir } from "virtual:funstack/config";
67

78
export interface DeferEntry {
89
state: DeferEntryState;
@@ -184,7 +185,7 @@ export function defer(
184185
const rawId = sanitizedName
185186
? `${sanitizedName}-${crypto.randomUUID()}`
186187
: crypto.randomUUID();
187-
const id = getPayloadIDFor(rawId);
188+
const id = getPayloadIDFor(rawId, rscPayloadDir);
188189
deferRegistry.register(element, id, name);
189190

190191
return <DeferredComponent moduleID={id} />;

packages/static/src/rsc/rscModule.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
/**
2-
* ID is prefixed with this string to form module path.
2+
* Default directory name for RSC payload files.
33
*/
4-
const rscPayloadIDPrefix = "fun:rsc-payload/";
4+
export const defaultRscPayloadDir = "fun:rsc-payload";
55

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

1417
const rscModulePathPrefix = "/funstack__/";

packages/static/src/virtual.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,6 @@ declare module "virtual:funstack/entries" {
55
}
66
declare module "virtual:funstack/config" {
77
export const ssr: boolean;
8+
export const rscPayloadDir: string;
89
}
910
declare module "virtual:funstack/client-init" {}

0 commit comments

Comments
 (0)