Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 11 additions & 15 deletions packages/plugin-rsc/examples/basic/src/framework/entry.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ import {
import React from 'react'
import { createRoot, hydrateRoot } from 'react-dom/client'
import { rscStream } from 'rsc-html-stream/client'
import type { RscPayload } from './entry.rsc'
import { GlobalErrorBoundary } from './error-boundary'
import type { RscPayload } from './entry.rsc'
import { createRscRenderRequest } from './request'

async function main() {
// stash `setPayload` function to trigger re-rendering
Expand Down Expand Up @@ -40,27 +41,22 @@ async function main() {

// re-fetch RSC and trigger re-rendering
async function fetchRscPayload() {
const payload = await createFromFetch<RscPayload>(
fetch(window.location.href),
)
const renderRequest = createRscRenderRequest(window.location.href)
const payload = await createFromFetch<RscPayload>(fetch(renderRequest))
setPayload(payload)
}

// register a handler which will be internally called by React
// on server function request after hydration.
setServerCallback(async (id, args) => {
const url = new URL(window.location.href)
const temporaryReferences = createTemporaryReferenceSet()
const payload = await createFromFetch<RscPayload>(
fetch(url, {
method: 'POST',
body: await encodeReply(args, { temporaryReferences }),
headers: {
'x-rsc-action': id,
},
}),
{ temporaryReferences },
)
const renderRequest = createRscRenderRequest(window.location.href, {
id,
body: await encodeReply(args, { temporaryReferences }),
})
const payload = await createFromFetch<RscPayload>(fetch(renderRequest), {
temporaryReferences,
})
setPayload(payload)
const { ok, data } = payload.returnValue!
if (!ok) throw data
Expand Down
82 changes: 59 additions & 23 deletions packages/plugin-rsc/examples/basic/src/framework/entry.rsc.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
} from '@vitejs/plugin-rsc/rsc'
import type { ReactFormState } from 'react-dom/client'
import type React from 'react'
import { parseRenderRequest } from './request.tsx'
import '../styles.css'

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

// the plugin by default assumes `rsc` entry having default export of request handler.
// however, how server entries are executed can be customized by registering
// own server handler e.g. `@cloudflare/vite-plugin`.
export async function handleRequest({
async function handleRequest({
request,
getRoot,
nonce,
Expand All @@ -34,23 +33,24 @@ export async function handleRequest({
getRoot: () => React.ReactNode
nonce?: string
}): Promise<Response> {
// differentiate RSC, SSR, action, etc.
const renderRequest = parseRenderRequest(request)

// handle server function request
const isAction = request.method === 'POST'
let returnValue: RscPayload['returnValue'] | undefined
let formState: ReactFormState | undefined
let temporaryReferences: unknown | undefined
let actionStatus: number | undefined
if (isAction) {
// x-rsc-action header exists when action is called via `ReactClient.setServerCallback`.
const actionId = request.headers.get('x-rsc-action')
if (actionId) {
if (renderRequest.isAction === true) {
if (renderRequest.actionId) {
// action is called via `ReactClient.setServerCallback`.
const contentType = request.headers.get('content-type')
const body = contentType?.startsWith('multipart/form-data')
? await request.formData()
: await request.text()
temporaryReferences = createTemporaryReferenceSet()
const args = await decodeReply(body, { temporaryReferences })
const action = await loadServerAction(actionId)
const action = await loadServerAction(renderRequest.actionId)
try {
const data = await action.apply(null, args)
returnValue = { ok: true, data }
Expand All @@ -77,25 +77,16 @@ export async function handleRequest({
}
}

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

// respond RSC stream without HTML rendering based on framework's convention.
// here we use request header `content-type`.
// additionally we allow `?__rsc` and `?__html` to easily view payload directly.
const isRscRequest =
(!request.headers.get('accept')?.includes('text/html') &&
!url.searchParams.has('__html')) ||
url.searchParams.has('__rsc')

if (isRscRequest) {
// Respond RSC stream without HTML rendering as decided by `RenderRequest`
if (renderRequest.isRsc) {
return new Response(rscStream, {
status: actionStatus,
headers: {
'content-type': 'text/x-component;charset=utf-8',
vary: 'accept',
},
})
}
Expand All @@ -111,15 +102,60 @@ export async function handleRequest({
formState,
nonce,
// allow quick simulation of javascript disabled browser
debugNojs: url.searchParams.has('__nojs'),
debugNojs: renderRequest.url.searchParams.has('__nojs'),
})

// respond html
return new Response(ssrResult.stream, {
status: ssrResult.status,
headers: {
'content-type': 'text/html;charset=utf-8',
vary: 'accept',
},
})
}

async function handler(request: Request): Promise<Response> {
const url = new URL(request.url)

const { Root } = await import('../routes/root.tsx')
const nonce = !process.env.NO_CSP ? crypto.randomUUID() : undefined
// https://vite.dev/guide/features.html#content-security-policy-csp
// this isn't needed if `style-src: 'unsafe-inline'` (dev) and `script-src: 'self'`
const nonceMeta = nonce && <meta property="csp-nonce" nonce={nonce} />
const root = (
<>
{/* this `loadCss` only collects `styles.css` but not css inside dynamic import `root.tsx` */}
{import.meta.viteRsc.loadCss()}
{nonceMeta}
<Root url={url} />
</>
)
const response = await handleRequest({
request,
getRoot: () => root,
nonce,
})
if (nonce && response.headers.get('content-type')?.includes('text/html')) {
const cspValue = [
`default-src 'self';`,
// `unsafe-eval` is required during dev since React uses eval for findSourceMapURL feature
`script-src 'self' 'nonce-${nonce}' ${import.meta.env.DEV ? `'unsafe-eval'` : ``};`,
`style-src 'self' 'unsafe-inline';`,
`img-src 'self' data:;`,
// allow blob: worker for Vite server ping shared worker
import.meta.hot && `worker-src 'self' blob:;`,
]
.filter(Boolean)
.join('')
response.headers.set('content-security-policy', cspValue)
}
return response
}

export default {
fetch: handler,
}

if (import.meta.hot) {
import.meta.hot.accept()
}
58 changes: 58 additions & 0 deletions packages/plugin-rsc/examples/basic/src/framework/request.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Framework conventions (arbitrary choices for this demo):
// - Use `_.rsc` URL suffix to differentiate RSC requests from SSR requests
// - Use `x-rsc-action` header to pass server action ID
const URL_POSTFIX = '_.rsc'
const HEADER_ACTION_ID = 'x-rsc-action'

// Parsed request information used to route between RSC/SSR rendering and action handling.
// Created by parseRenderRequest() from incoming HTTP requests.
type RenderRequest = {
isRsc: boolean // true if request should return RSC payload (via _.rsc suffix)
isAction: boolean // true if this is a server action call (POST request)
actionId?: string // server action ID from x-rsc-action header
request: Request // normalized Request with _.rsc suffix removed from URL
url: URL // normalized URL with _.rsc suffix removed
}

export function createRscRenderRequest(
urlString: string,
action?: { id: string; body: BodyInit },
): Request {
const url = new URL(urlString)
url.pathname += URL_POSTFIX
const headers = new Headers()
if (action) {
headers.set(HEADER_ACTION_ID, action.id)
}
return new Request(url.toString(), {
method: action ? 'POST' : 'GET',
headers,
body: action?.body,
})
}

export function parseRenderRequest(request: Request): RenderRequest {
const url = new URL(request.url)
const isAction = request.method === 'POST'
if (url.pathname.endsWith(URL_POSTFIX)) {
url.pathname = url.pathname.slice(0, -URL_POSTFIX.length)
const actionId = request.headers.get(HEADER_ACTION_ID) || undefined
if (request.method === 'POST' && !actionId) {
throw new Error('Missing action id header for RSC action request')
}
return {
isRsc: true,
isAction,
actionId,
request: new Request(url, request),
url,
}
} else {
return {
isRsc: false,
isAction,
request,
url,
}
}
}
47 changes: 0 additions & 47 deletions packages/plugin-rsc/examples/basic/src/server.tsx

This file was deleted.

6 changes: 3 additions & 3 deletions packages/plugin-rsc/examples/basic/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export default defineConfig({
entries: {
client: './src/framework/entry.browser.tsx',
ssr: './src/framework/entry.ssr.tsx',
rsc: './src/server.tsx',
rsc: './src/framework/entry.rsc.tsx',
},
// disable auto css injection to manually test `loadCss` feature.
rscCssTransform: false,
Expand Down Expand Up @@ -91,13 +91,13 @@ export default defineConfig({
assert(viteManifest.type === 'asset')
assert(typeof viteManifest.source === 'string')
if (this.environment.name === 'rsc') {
assert(viteManifest.source.includes('src/server.tsx'))
assert(viteManifest.source.includes('src/framework/entry.rsc.tsx'))
assert(
!viteManifest.source.includes('src/framework/entry.browser.tsx'),
)
}
if (this.environment.name === 'client') {
assert(!viteManifest.source.includes('src/server.tsx'))
assert(!viteManifest.source.includes('src/framework/entry.rsc.tsx'))
assert(
viteManifest.source.includes('src/framework/entry.browser.tsx'),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,6 @@ export async function fetchServer(request: Request): Promise<Response> {
status: returnValue?.ok === false ? 500 : undefined,
headers: {
'content-type': 'text/x-component;charset=utf-8',
vary: 'accept',
},
})
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ async function handler(request: Request): Promise<Response> {
status: returnValue?.ok === false ? 500 : undefined,
headers: {
'content-type': 'text/x-component;charset=utf-8',
vary: 'accept',
},
})
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ export default async function handler(request: Request): Promise<Response> {
status: returnValue?.ok === false ? 500 : undefined,
headers: {
'content-type': 'text/x-component;charset=utf-8',
vary: 'accept',
},
})
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ import {
import React from 'react'
import { createRoot, hydrateRoot } from 'react-dom/client'
import { rscStream } from 'rsc-html-stream/client'
import { RSC_POSTFIX, type RscPayload } from './shared'
import { GlobalErrorBoundary } from './error-boundary'
import { createRscRenderRequest } from './request'
import type { RscPayload } from './shared'

async function hydrate(): Promise<void> {
async function onNavigation() {
const url = new URL(window.location.href)
url.pathname = url.pathname + RSC_POSTFIX
const payload = await createFromFetch<RscPayload>(fetch(url))
const renderRequest = createRscRenderRequest(window.location.href)
const payload = await createFromFetch<RscPayload>(fetch(renderRequest))
setPayload(payload)
}

Expand Down
Loading
Loading