Skip to content

Commit 2994900

Browse files
Copilothi-ogawaclaude
authored
chore(rsc/example): use different url for RSC and SSR requests (#975)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: hi-ogawa <4232207+hi-ogawa@users.noreply.github.com> Co-authored-by: Hiroshi Ogawa <hi.ogawa.zz@gmail.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent a06940b commit 2994900

File tree

19 files changed

+339
-180
lines changed

19 files changed

+339
-180
lines changed

packages/plugin-rsc/examples/basic/src/framework/entry.browser.tsx

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ import {
88
import React from 'react'
99
import { createRoot, hydrateRoot } from 'react-dom/client'
1010
import { rscStream } from 'rsc-html-stream/client'
11-
import type { RscPayload } from './entry.rsc'
1211
import { GlobalErrorBoundary } from './error-boundary'
12+
import type { RscPayload } from './entry.rsc'
13+
import { createRscRenderRequest } from './request'
1314

1415
async function main() {
1516
// stash `setPayload` function to trigger re-rendering
@@ -40,27 +41,22 @@ async function main() {
4041

4142
// re-fetch RSC and trigger re-rendering
4243
async function fetchRscPayload() {
43-
const payload = await createFromFetch<RscPayload>(
44-
fetch(window.location.href),
45-
)
44+
const renderRequest = createRscRenderRequest(window.location.href)
45+
const payload = await createFromFetch<RscPayload>(fetch(renderRequest))
4646
setPayload(payload)
4747
}
4848

4949
// register a handler which will be internally called by React
5050
// on server function request after hydration.
5151
setServerCallback(async (id, args) => {
52-
const url = new URL(window.location.href)
5352
const temporaryReferences = createTemporaryReferenceSet()
54-
const payload = await createFromFetch<RscPayload>(
55-
fetch(url, {
56-
method: 'POST',
57-
body: await encodeReply(args, { temporaryReferences }),
58-
headers: {
59-
'x-rsc-action': id,
60-
},
61-
}),
62-
{ temporaryReferences },
63-
)
53+
const renderRequest = createRscRenderRequest(window.location.href, {
54+
id,
55+
body: await encodeReply(args, { temporaryReferences }),
56+
})
57+
const payload = await createFromFetch<RscPayload>(fetch(renderRequest), {
58+
temporaryReferences,
59+
})
6460
setPayload(payload)
6561
const { ok, data } = payload.returnValue!
6662
if (!ok) throw data

packages/plugin-rsc/examples/basic/src/framework/entry.rsc.tsx

Lines changed: 59 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import {
88
} from '@vitejs/plugin-rsc/rsc'
99
import type { ReactFormState } from 'react-dom/client'
1010
import type React from 'react'
11+
import { parseRenderRequest } from './request.tsx'
12+
import '../styles.css'
1113

1214
// The schema of payload which is serialized into RSC stream on rsc environment
1315
// and deserialized on ssr/client environments.
@@ -22,10 +24,7 @@ export type RscPayload = {
2224
formState?: ReactFormState
2325
}
2426

25-
// the plugin by default assumes `rsc` entry having default export of request handler.
26-
// however, how server entries are executed can be customized by registering
27-
// own server handler e.g. `@cloudflare/vite-plugin`.
28-
export async function handleRequest({
27+
async function handleRequest({
2928
request,
3029
getRoot,
3130
nonce,
@@ -34,23 +33,24 @@ export async function handleRequest({
3433
getRoot: () => React.ReactNode
3534
nonce?: string
3635
}): Promise<Response> {
36+
// differentiate RSC, SSR, action, etc.
37+
const renderRequest = parseRenderRequest(request)
38+
3739
// handle server function request
38-
const isAction = request.method === 'POST'
3940
let returnValue: RscPayload['returnValue'] | undefined
4041
let formState: ReactFormState | undefined
4142
let temporaryReferences: unknown | undefined
4243
let actionStatus: number | undefined
43-
if (isAction) {
44-
// x-rsc-action header exists when action is called via `ReactClient.setServerCallback`.
45-
const actionId = request.headers.get('x-rsc-action')
46-
if (actionId) {
44+
if (renderRequest.isAction === true) {
45+
if (renderRequest.actionId) {
46+
// action is called via `ReactClient.setServerCallback`.
4747
const contentType = request.headers.get('content-type')
4848
const body = contentType?.startsWith('multipart/form-data')
4949
? await request.formData()
5050
: await request.text()
5151
temporaryReferences = createTemporaryReferenceSet()
5252
const args = await decodeReply(body, { temporaryReferences })
53-
const action = await loadServerAction(actionId)
53+
const action = await loadServerAction(renderRequest.actionId)
5454
try {
5555
const data = await action.apply(null, args)
5656
returnValue = { ok: true, data }
@@ -77,25 +77,16 @@ export async function handleRequest({
7777
}
7878
}
7979

80-
const url = new URL(request.url)
8180
const rscPayload: RscPayload = { root: getRoot(), formState, returnValue }
8281
const rscOptions = { temporaryReferences }
8382
const rscStream = renderToReadableStream<RscPayload>(rscPayload, rscOptions)
8483

85-
// respond RSC stream without HTML rendering based on framework's convention.
86-
// here we use request header `content-type`.
87-
// additionally we allow `?__rsc` and `?__html` to easily view payload directly.
88-
const isRscRequest =
89-
(!request.headers.get('accept')?.includes('text/html') &&
90-
!url.searchParams.has('__html')) ||
91-
url.searchParams.has('__rsc')
92-
93-
if (isRscRequest) {
84+
// Respond RSC stream without HTML rendering as decided by `RenderRequest`
85+
if (renderRequest.isRsc) {
9486
return new Response(rscStream, {
9587
status: actionStatus,
9688
headers: {
9789
'content-type': 'text/x-component;charset=utf-8',
98-
vary: 'accept',
9990
},
10091
})
10192
}
@@ -111,15 +102,60 @@ export async function handleRequest({
111102
formState,
112103
nonce,
113104
// allow quick simulation of javascript disabled browser
114-
debugNojs: url.searchParams.has('__nojs'),
105+
debugNojs: renderRequest.url.searchParams.has('__nojs'),
115106
})
116107

117108
// respond html
118109
return new Response(ssrResult.stream, {
119110
status: ssrResult.status,
120111
headers: {
121112
'content-type': 'text/html;charset=utf-8',
122-
vary: 'accept',
123113
},
124114
})
125115
}
116+
117+
async function handler(request: Request): Promise<Response> {
118+
const url = new URL(request.url)
119+
120+
const { Root } = await import('../routes/root.tsx')
121+
const nonce = !process.env.NO_CSP ? crypto.randomUUID() : undefined
122+
// https://vite.dev/guide/features.html#content-security-policy-csp
123+
// this isn't needed if `style-src: 'unsafe-inline'` (dev) and `script-src: 'self'`
124+
const nonceMeta = nonce && <meta property="csp-nonce" nonce={nonce} />
125+
const root = (
126+
<>
127+
{/* this `loadCss` only collects `styles.css` but not css inside dynamic import `root.tsx` */}
128+
{import.meta.viteRsc.loadCss()}
129+
{nonceMeta}
130+
<Root url={url} />
131+
</>
132+
)
133+
const response = await handleRequest({
134+
request,
135+
getRoot: () => root,
136+
nonce,
137+
})
138+
if (nonce && response.headers.get('content-type')?.includes('text/html')) {
139+
const cspValue = [
140+
`default-src 'self';`,
141+
// `unsafe-eval` is required during dev since React uses eval for findSourceMapURL feature
142+
`script-src 'self' 'nonce-${nonce}' ${import.meta.env.DEV ? `'unsafe-eval'` : ``};`,
143+
`style-src 'self' 'unsafe-inline';`,
144+
`img-src 'self' data:;`,
145+
// allow blob: worker for Vite server ping shared worker
146+
import.meta.hot && `worker-src 'self' blob:;`,
147+
]
148+
.filter(Boolean)
149+
.join('')
150+
response.headers.set('content-security-policy', cspValue)
151+
}
152+
return response
153+
}
154+
155+
export default {
156+
fetch: handler,
157+
}
158+
159+
if (import.meta.hot) {
160+
import.meta.hot.accept()
161+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// Framework conventions (arbitrary choices for this demo):
2+
// - Use `_.rsc` URL suffix to differentiate RSC requests from SSR requests
3+
// - Use `x-rsc-action` header to pass server action ID
4+
const URL_POSTFIX = '_.rsc'
5+
const HEADER_ACTION_ID = 'x-rsc-action'
6+
7+
// Parsed request information used to route between RSC/SSR rendering and action handling.
8+
// Created by parseRenderRequest() from incoming HTTP requests.
9+
type RenderRequest = {
10+
isRsc: boolean // true if request should return RSC payload (via _.rsc suffix)
11+
isAction: boolean // true if this is a server action call (POST request)
12+
actionId?: string // server action ID from x-rsc-action header
13+
request: Request // normalized Request with _.rsc suffix removed from URL
14+
url: URL // normalized URL with _.rsc suffix removed
15+
}
16+
17+
export function createRscRenderRequest(
18+
urlString: string,
19+
action?: { id: string; body: BodyInit },
20+
): Request {
21+
const url = new URL(urlString)
22+
url.pathname += URL_POSTFIX
23+
const headers = new Headers()
24+
if (action) {
25+
headers.set(HEADER_ACTION_ID, action.id)
26+
}
27+
return new Request(url.toString(), {
28+
method: action ? 'POST' : 'GET',
29+
headers,
30+
body: action?.body,
31+
})
32+
}
33+
34+
export function parseRenderRequest(request: Request): RenderRequest {
35+
const url = new URL(request.url)
36+
const isAction = request.method === 'POST'
37+
if (url.pathname.endsWith(URL_POSTFIX)) {
38+
url.pathname = url.pathname.slice(0, -URL_POSTFIX.length)
39+
const actionId = request.headers.get(HEADER_ACTION_ID) || undefined
40+
if (request.method === 'POST' && !actionId) {
41+
throw new Error('Missing action id header for RSC action request')
42+
}
43+
return {
44+
isRsc: true,
45+
isAction,
46+
actionId,
47+
request: new Request(url, request),
48+
url,
49+
}
50+
} else {
51+
return {
52+
isRsc: false,
53+
isAction,
54+
request,
55+
url,
56+
}
57+
}
58+
}

packages/plugin-rsc/examples/basic/src/server.tsx

Lines changed: 0 additions & 47 deletions
This file was deleted.

packages/plugin-rsc/examples/basic/vite.config.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export default defineConfig({
2525
entries: {
2626
client: './src/framework/entry.browser.tsx',
2727
ssr: './src/framework/entry.ssr.tsx',
28-
rsc: './src/server.tsx',
28+
rsc: './src/framework/entry.rsc.tsx',
2929
},
3030
// disable auto css injection to manually test `loadCss` feature.
3131
rscCssTransform: false,
@@ -91,13 +91,13 @@ export default defineConfig({
9191
assert(viteManifest.type === 'asset')
9292
assert(typeof viteManifest.source === 'string')
9393
if (this.environment.name === 'rsc') {
94-
assert(viteManifest.source.includes('src/server.tsx'))
94+
assert(viteManifest.source.includes('src/framework/entry.rsc.tsx'))
9595
assert(
9696
!viteManifest.source.includes('src/framework/entry.browser.tsx'),
9797
)
9898
}
9999
if (this.environment.name === 'client') {
100-
assert(!viteManifest.source.includes('src/server.tsx'))
100+
assert(!viteManifest.source.includes('src/framework/entry.rsc.tsx'))
101101
assert(
102102
viteManifest.source.includes('src/framework/entry.browser.tsx'),
103103
)

packages/plugin-rsc/examples/browser-mode/src/framework/entry.rsc.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,6 @@ export async function fetchServer(request: Request): Promise<Response> {
7373
status: returnValue?.ok === false ? 500 : undefined,
7474
headers: {
7575
'content-type': 'text/x-component;charset=utf-8',
76-
vary: 'accept',
7776
},
7877
})
7978
}

packages/plugin-rsc/examples/browser/src/framework/entry.rsc.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@ async function handler(request: Request): Promise<Response> {
5252
status: returnValue?.ok === false ? 500 : undefined,
5353
headers: {
5454
'content-type': 'text/x-component;charset=utf-8',
55-
vary: 'accept',
5655
},
5756
})
5857
}

packages/plugin-rsc/examples/no-ssr/src/framework/entry.rsc.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@ export default async function handler(request: Request): Promise<Response> {
5252
status: returnValue?.ok === false ? 500 : undefined,
5353
headers: {
5454
'content-type': 'text/x-component;charset=utf-8',
55-
vary: 'accept',
5655
},
5756
})
5857
}

packages/plugin-rsc/examples/ssg/src/framework/entry.browser.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@ import {
55
import React from 'react'
66
import { createRoot, hydrateRoot } from 'react-dom/client'
77
import { rscStream } from 'rsc-html-stream/client'
8-
import { RSC_POSTFIX, type RscPayload } from './shared'
98
import { GlobalErrorBoundary } from './error-boundary'
9+
import { createRscRenderRequest } from './request'
10+
import type { RscPayload } from './shared'
1011

1112
async function hydrate(): Promise<void> {
1213
async function onNavigation() {
13-
const url = new URL(window.location.href)
14-
url.pathname = url.pathname + RSC_POSTFIX
15-
const payload = await createFromFetch<RscPayload>(fetch(url))
14+
const renderRequest = createRscRenderRequest(window.location.href)
15+
const payload = await createFromFetch<RscPayload>(fetch(renderRequest))
1616
setPayload(payload)
1717
}
1818

0 commit comments

Comments
 (0)