diff --git a/packages/plugin-rsc/README.md b/packages/plugin-rsc/README.md index 92879dd25..628855028 100644 --- a/packages/plugin-rsc/README.md +++ b/packages/plugin-rsc/README.md @@ -518,6 +518,32 @@ function renderToReadableStream( ): ReadableStream ``` +#### Vite-specific extension: `decodeReply`, `decodeAction`, and `decodeFormState` (experimental) + +> [!NOTE] +> This is a Vite-specific extension to the standard React RSC API. The official `react-server-dom` does not provide this callback mechanism. + +`decodeReply`, `decodeAction`, and `decodeFormState` (and `decodeReplyFromBusboy` in `@vitejs/plugin-rsc/rsc/server.node`) are extended with an optional last parameter with `onServerReference` callback. +This is invoked whenever React resolves a server reference while decoding: the action itself in `decodeAction`, and any server function passed as an argument or bound to an action. +`id` and `name` match the `${id}#${name}` id that `loadServerAction` receives. +Throwing from the callback rejects the reference before its module is loaded, which allows a framework to limit decoding to the server functions it exposes for the request. +`loadServerAction` doesn't invoke the callback, so a framework should check the action id it passes there separately. + +```ts +function decodeReply( + body: string | FormData, + // standard options (e.g. temporaryReferences) + options?: object, + // vite-specific options + extraOptions?: { + onServerReference?: (metadata: { id: string; name: string }) => void + }, +): Promise + +// `decodeAction(body, extraOptions?)` and +// `decodeFormState(actionResult, body, extraOptions?)` accept the same `extraOptions`. +``` + ### `@vitejs/plugin-rsc/ssr` This module provides Vite-integrated RSC runtime APIs based on diff --git a/packages/plugin-rsc/e2e/action-reachability.test.ts b/packages/plugin-rsc/e2e/action-reachability.test.ts index 7cbf2d7a4..216ceeb3d 100644 --- a/packages/plugin-rsc/e2e/action-reachability.test.ts +++ b/packages/plugin-rsc/e2e/action-reachability.test.ts @@ -31,6 +31,44 @@ test.describe('build', () => { ).toBeVisible() }) + test('rejects a retained action passed as an argument', async ({ page }) => { + // Action B is reachable from /b, but the retained action A in its + // arguments is not. + await page.goto(f.url('/a')) + await waitForHydration(page) + await page.getByRole('button', { name: 'Save action A' }).click() + await page.getByRole('link', { name: '/b' }).click() + await expect( + page.getByRole('button', { name: 'Run action B with saved action' }), + ).toBeEnabled() + + const responsePromise = page.waitForResponse( + (response) => response.request().method() === 'POST', + ) + await page + .getByRole('button', { name: 'Run action B with saved action' }) + .click() + const response = await responsePromise + expect(response.status()).toBe(404) + expect(await response.text()).toContain('Server action is not reachable') + await expect(page.getByText('Result: none')).toBeVisible() + }) + + test('accepts a reachable action passed as an argument', async ({ page }) => { + await page.goto(f.url('/b')) + await waitForHydration(page) + await page.getByRole('button', { name: 'Save action B' }).click() + // Navigate away and back to re-render with the saved action. + await page.getByRole('link', { name: '/a' }).click() + await page.getByRole('link', { name: '/b' }).click() + await page + .getByRole('button', { name: 'Run action B with saved action' }) + .click() + await expect( + page.getByText('Result: ACTION_B_WITH:ACTION_B_OK:MIDDLEWARE_B'), + ).toBeVisible() + }) + testNoJs( 'unbound progressive action is limited to its route', async ({ page }) => { @@ -45,7 +83,27 @@ test.describe('build', () => { }, ) - async function testProgressiveAction(page: Page, name: 'Unbound' | 'Bound') { + testNoJs( + 'server function bound to a progressive action is limited to its route', + async ({ page }) => { + await testProgressiveAction(page, 'Shared') + }, + ) + + /** + * Submits one of the progressive forms on `/c` without JavaScript, which must + * run on `/c`, then retargets the same form to `/b` to simulate a stale or + * manipulated submission, which must be rejected with 404. + * + * `decodeAction` rejects all three on `/b`, at a different reference: + * - Unbound: the action from the `$ACTION_ID_*` field. + * - Bound: the action from the `$ACTION_REF_*` metadata. + * - Shared: the action bound to it, since `/b` also reaches the shared action. + */ + async function testProgressiveAction( + page: Page, + name: 'Unbound' | 'Bound' | 'Shared', + ) { // Without JavaScript, React submits the action as a native full-page (MPA) // multipart form request to the route that rendered it. await page.goto(f.url('/c')) diff --git a/packages/plugin-rsc/e2e/server-reference-callback.test.ts b/packages/plugin-rsc/e2e/server-reference-callback.test.ts new file mode 100644 index 000000000..3fbc9af34 --- /dev/null +++ b/packages/plugin-rsc/e2e/server-reference-callback.test.ts @@ -0,0 +1,136 @@ +import { expect, test } from '@playwright/test' +import { setupInlineFixture, type Fixture, useFixture } from './fixture' +import { waitForHydration } from './helper' + +test.describe(() => { + const root = 'examples/e2e/temp/server-reference-callback' + + test.beforeAll(async () => { + await setupInlineFixture({ + src: 'examples/starter-extra', + dest: root, + files: { + 'src/root.tsx': /* tsx */ ` + import { + allowedReference, + describeArgument, + rejectedReference, + } from './server-reference.tsx' + import { calls } from './calls.tsx' + + export function Root() { + return ( + + + + + +
+ +
+
+ +
+

[{calls.join(',')}]

+ + + ) + } + `, + 'src/calls.tsx': /* tsx */ ` + export const calls: string[] = [] + `, + 'src/server-reference.tsx': /* tsx */ ` + 'use server' + import { calls } from './calls.tsx' + + export async function describeArgument(argument: unknown) { + calls.push(typeof argument) + } + + export async function allowedReference() {} + + export async function rejectedReference() {} + `, + 'src/framework/entry.rsc.tsx': { + edit: (s) => + s + .replace( + `decodeReply(body, { temporaryReferences })`, + `decodeReply(body, { temporaryReferences }, { onServerReference })`, + ) + .replace( + `decodeAction(formData)`, + `decodeAction(formData, { onServerReference })`, + ) + .replace( + `decodeFormState(result, formData)`, + `decodeFormState(result, formData, { onServerReference })`, + ) + + /* tsx */ ` +function onServerReference(metadata: { id: string; name: string }) { + if (metadata.name === 'rejectedReference') { + throw new Error('rejected server reference: ' + metadata.id + '#' + metadata.name) + } +} +`, + }, + }, + }) + }) + + function defineServerReferenceCallbackTest(f: Fixture) { + test('onServerReference rejects decoded server references', async ({ + browser, + page, + }) => { + // decodeReply + await page.goto(f.url()) + await waitForHydration(page) + const rejected = page.waitForResponse( + (response) => response.request().method() === 'POST', + ) + await page.getByRole('button', { name: 'rejected' }).click() + expect((await rejected).status()).toBe(500) + + await page.goto(f.url()) + await waitForHydration(page) + await expect(page.getByTestId('calls')).toHaveText('[]') + await page.getByRole('button', { name: 'allowed' }).click() + await expect(page.getByTestId('calls')).toHaveText('[function]') + + // decodeAction + const context = await browser.newContext({ javaScriptEnabled: false }) + try { + const nojsPage = await context.newPage() + await nojsPage.goto(f.url()) + const [rejectedForm] = await Promise.all([ + nojsPage.waitForResponse( + (response) => response.request().method() === 'POST', + ), + nojsPage.getByRole('button', { name: 'rejected' }).click(), + ]) + expect(rejectedForm.status()).toBe(500) + + await nojsPage.goto(f.url()) + await expect(nojsPage.getByTestId('calls')).toHaveText('[function]') + await nojsPage.getByRole('button', { name: 'allowed' }).click() + await expect(nojsPage.getByTestId('calls')).toHaveText( + '[function,function]', + ) + } finally { + await context.close() + } + }) + } + + test.describe('dev', () => { + const f = useFixture({ root, mode: 'dev' }) + defineServerReferenceCallbackTest(f) + }) + + test.describe('build', () => { + const f = useFixture({ root, mode: 'build' }) + defineServerReferenceCallbackTest(f) + }) +}) diff --git a/packages/plugin-rsc/examples/action-reachability/README.md b/packages/plugin-rsc/examples/action-reachability/README.md index e8d058931..1a2a3c08c 100644 --- a/packages/plugin-rsc/examples/action-reachability/README.md +++ b/packages/plugin-rsc/examples/action-reachability/README.md @@ -15,9 +15,9 @@ The example follows this sequence: | Production | `/a` middleware | `ACTION_A_OK:MIDDLEWARE_A` | `/b` | | Development | `/b` middleware | `ACTION_A_OK:MIDDLEWARE_B` | `/b` | -In production, a generated route-action manifest lets the RSC handler redispatch the action request through middleware for a page whose graph reaches the action. This example enables manifest routing only in production, so development stays on the current route. +In production, a generated route-action manifest tells the RSC handler which server references each route reaches. The handler uses it to redispatch an action request through middleware for a page whose graph reaches the action, and to reject server references that the route running the action cannot reach. The manifest is build-time data, so development stays on the current route and decodes every server reference. -## Application graphs +## Manifest generation For simplicity, the app routes and their graph roots are declared manually. Route `/a` reaches action A through a Client Component and an ordinary runtime return value: @@ -28,22 +28,29 @@ src/app/a/page.tsx -> action.tsx ("use server") ``` -## Manifest generation +The final RSC graph ends at the Client Component proxy, so the edge to action A exists only in the final client graph. During the RSC build, the manifest plugin traverses each route graph and records directly reachable server reference IDs and reachable Client Component import IDs. During the client build, it traverses the client graph from those import IDs and records reachable server reference IDs. For each route, it unions the two sets, and after all environment builds finish, it writes the mapping into the RSC output for runtime use. -During the RSC build, the manifest plugin traverses each route graph and records directly reachable server reference IDs and reachable Client Component import IDs. During the client build, it traverses the final client graph from those import IDs and records reachable server reference IDs. For each route, it unions the directly reachable IDs with the IDs reachable through its Client Components. +![The manifest joins the RSC graph up to Client Components with the client graph from there to server references](images/manifest.svg) -After all environment builds finish, the plugin installs the mapping in the RSC output for runtime routing. +Each entry has the same `#` form that the RSC runtime uses for server references, so the runtime checks compare strings directly. ## Request redispatch -For the production scenario above, the RSC handler finds action A under `/a` in the manifest and creates a new action request for `/a`. That request re-enters `/a` middleware, so the action observes `MIDDLEWARE_A`. It also preserves `/b` as the render URL, so the response continues rendering page B. +A hydrated action call carries its action ID in a request header, so the handler can choose a route before decoding the body. For the production scenario above, the handler finds action A under `/a` in the manifest and creates a new action request for `/a`. That request re-enters `/a` middleware inside the original `/b` request, so the action observes `MIDDLEWARE_A`. It also preserves `/b` as the render URL, so the response continues rendering page B. + +![The /b handler redispatches action A to /a, whose middleware runs the action, and the response renders page B](images/redispatch.svg) Development skips route-aware redispatch. The handler executes action A on `/b`, so the action observes `MIDDLEWARE_B`. -## Protocol scope +## Nested server references and progressive forms + +Routing sees only the outer action ID, but server functions can also arrive nested inside a request, as an argument to the action or bound to it. React finds these only while `decodeReply()` or `decodeAction()` decodes the body, so the handler passes an `onServerReference` callback that checks every server reference against the route running the action. The callback throws before an unreachable reference's module loads, and the handler responds with 404. + +Progressive forms have no action ID header and normally post back to the route that rendered them. A mismatched progressive action is therefore rejected as stale or manipulated input rather than redispatched, and this decode-time check is the only one it passes through. Next.js similarly validates progressive form fields against the current page's server module map, using a [pre-decode parser](https://github.com/vercel/next.js/blob/aae4179ac628e55483b62cd023a7e1827dcef122/packages/next/src/server/app-render/action-handler.ts#L1467-L1576) of React's multipart fields. -Hydrated action calls carry an explicit action ID and can be redispatched to a route whose graph reaches the action. Progressive forms normally post back to the route that rendered them, so their multipart `$ACTION_*` fields are instead validated against the current route before `decodeAction()` can load the submitted references. A mismatched progressive action is rejected as stale or manipulated input rather than redispatched. +![With JS, routing accepts the outer ID but decoding rejects the nested action A. Without JS, decoding accepts the shared action but rejects the bound /c action](images/nested-check.svg) -Route `/c` demonstrates this behavior with unbound and bound progressive forms. Their normal submissions run through `/c` middleware, while replaying the same multipart fields to another route is rejected. +The example covers both cases: -The route manifest is build-time data, so this progressive validation is production-only. Development keeps the baseline `decodeAction()` behavior. +- On `/b`, passing a retained action A as an argument to an action on `/b` is rejected even though the outer action is reachable from `/b`. Passing a retained action B succeeds. Next.js also routes this request by the outer action ID only, and decoding then fails to find action A ([vercel/next.js#96331](https://github.com/vercel/next.js/issues/96331)). +- Route `/c` renders unbound and bound progressive forms, and a shared action that `/b` also renders, bound to an action only `/c` reaches. Their normal submissions run through `/c` middleware, while replaying the same multipart fields to `/b` is rejected. diff --git a/packages/plugin-rsc/examples/action-reachability/images/manifest.svg b/packages/plugin-rsc/examples/action-reachability/images/manifest.svg new file mode 100644 index 000000000..77baf2782 --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/images/manifest.svg @@ -0,0 +1,48 @@ + + + + + + + + + + 1. RSC build graph from route /a roots + + root.tsx, a/page.tsx + + + a/client.tsx + client reference proxy, graph stops here + collects: server reference IDs imported directly + (none for /a) and client reference import IDs + + + + 2. Client build graph from those imports + + a/client.tsx ("use client") + + + a/action-indirect.ts + + + a/action.tsx ("use server") + server reference proxy: actionA + the action edge exists only in this graph + + + client reference + import ID + + + + union per route, written after both builds + + __route_action_manifest.js (build output) + + /a 598f…#actionA + /b 55d4…#actionB 55d4…#actionBWithCallback b3ee…#sharedProgressiveAction + /c 9560…#progressiveActionC 9560…#boundProgressiveActionC b3ee…#sharedProgressiveAction + + diff --git a/packages/plugin-rsc/examples/action-reachability/images/nested-check.svg b/packages/plugin-rsc/examples/action-reachability/images/nested-check.svg new file mode 100644 index 000000000..eef8a1635 --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/images/nested-check.svg @@ -0,0 +1,45 @@ + + + + + + Every reference React resolves while decoding is checked against the route that runs the action + + + + With JS: decodeReply + + POST /b_.rsc + x-rsc-action: actionBWithCallback + body: [ref actionA] + + + + routing: outer ID in /b + ✓ continue on /b + + + decode: nested actionA in /b? + ✗ throw → 404 + Routing sees only the outer ID. The nested reference to actionA is found during decoding, before a/action.tsx loads. + + + + Without JS: decodeAction + + POST /b (fields from /c) + sharedProgressiveAction + .bind(progressiveActionC) + + + + decode: shared action in /b + ✓ /b renders it too + + + decode: bound C action in /b? + ✗ throw → 404 + No action ID header, so there's no routing step. Progressive forms are rejected on mismatch, never redispatched. + + The check is onServerReference from createServerReferenceValidator(pathname). In dev there's no manifest, so it is not installed. + diff --git a/packages/plugin-rsc/examples/action-reachability/images/redispatch.svg b/packages/plugin-rsc/examples/action-reachability/images/redispatch.svg new file mode 100644 index 000000000..7fa2d3c55 --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/images/redispatch.svg @@ -0,0 +1,42 @@ + + + + + + + + + Browser on /b + saved actionA from /a + clicks "Run saved action" + + POST /b_.rsc + x-rsc-action: actionA + + + + + + handler: /b middleware (MIDDLEWARE_B) + routeActionRequest: manifest lists actionA under /a, not /b + → redispatch, a new request built from the same body + + POST /a_.rsc x-action-forwarded: 1 x-rsc-render-url: /b + + + + + handler: /a middleware (MIDDLEWARE_A) + routeActionRequest: actionA is under /a → continue + decodeReply → loadServerAction → actionA() + returns ACTION_A_OK:MIDDLEWARE_A + renders RouteRoot for render URL /b + so the response keeps showing page B + a second redispatch is refused: forwarded + wrong route → 404 + + + routeActionRequest outcomes + continue: the current route reaches the ID, or no manifest (dev) + redispatch: another route reaches the ID + reject 404: no route reaches the ID, or the request was already forwarded + diff --git a/packages/plugin-rsc/examples/action-reachability/src/app/b/action.tsx b/packages/plugin-rsc/examples/action-reachability/src/app/b/action.tsx index 0142ae85e..4d565c71f 100644 --- a/packages/plugin-rsc/examples/action-reachability/src/app/b/action.tsx +++ b/packages/plugin-rsc/examples/action-reachability/src/app/b/action.tsx @@ -5,3 +5,7 @@ import { getRequestContext } from '../request-context.ts' export async function actionB() { return `ACTION_B_OK:${getRequestContext().middlewareTag}` } + +export async function actionBWithCallback(callback: () => Promise) { + return `ACTION_B_WITH:${await callback()}` +} diff --git a/packages/plugin-rsc/examples/action-reachability/src/app/b/client.tsx b/packages/plugin-rsc/examples/action-reachability/src/app/b/client.tsx index c020191f0..3831d35a3 100644 --- a/packages/plugin-rsc/examples/action-reachability/src/app/b/client.tsx +++ b/packages/plugin-rsc/examples/action-reachability/src/app/b/client.tsx @@ -2,7 +2,7 @@ import React from 'react' import { getSavedAction, setSavedAction } from '../saved-action.ts' -import { actionB } from './action.tsx' +import { actionB, actionBWithCallback } from './action.tsx' export function ActionB() { const [result, setResult] = React.useState('none') @@ -17,6 +17,14 @@ export function ActionB() { > Run saved action +

Result: {result}

) diff --git a/packages/plugin-rsc/examples/action-reachability/src/app/b/page.tsx b/packages/plugin-rsc/examples/action-reachability/src/app/b/page.tsx index 5469d352a..f8490d8b0 100644 --- a/packages/plugin-rsc/examples/action-reachability/src/app/b/page.tsx +++ b/packages/plugin-rsc/examples/action-reachability/src/app/b/page.tsx @@ -1,3 +1,5 @@ +import { sharedProgressiveAction } from '../shared/action.tsx' +import { actionB } from './action.tsx' import { ActionB } from './client.tsx' export function Page() { @@ -5,6 +7,12 @@ export function Page() {

This is page "b"

+
+ +
) } diff --git a/packages/plugin-rsc/examples/action-reachability/src/app/c/page.tsx b/packages/plugin-rsc/examples/action-reachability/src/app/c/page.tsx index 67759c06a..e8760d1b4 100644 --- a/packages/plugin-rsc/examples/action-reachability/src/app/c/page.tsx +++ b/packages/plugin-rsc/examples/action-reachability/src/app/c/page.tsx @@ -1,3 +1,4 @@ +import { sharedProgressiveAction } from '../shared/action.tsx' import { boundProgressiveActionC, progressiveActionC } from './action.tsx' export function Page() { @@ -13,6 +14,12 @@ export function Page() { > +
+ +
) } diff --git a/packages/plugin-rsc/examples/action-reachability/src/app/shared/action.tsx b/packages/plugin-rsc/examples/action-reachability/src/app/shared/action.tsx new file mode 100644 index 000000000..b0fe11a03 --- /dev/null +++ b/packages/plugin-rsc/examples/action-reachability/src/app/shared/action.tsx @@ -0,0 +1,8 @@ +'use server' + +export async function sharedProgressiveAction( + callback: (formData: FormData) => Promise, + formData: FormData, +) { + await callback(formData) +} diff --git a/packages/plugin-rsc/examples/action-reachability/src/framework/action-routing.ts b/packages/plugin-rsc/examples/action-reachability/src/framework/action-routing.ts index 77d9534c6..949eee41a 100644 --- a/packages/plugin-rsc/examples/action-reachability/src/framework/action-routing.ts +++ b/packages/plugin-rsc/examples/action-reachability/src/framework/action-routing.ts @@ -48,107 +48,23 @@ export function routeActionRequest( } } -const $ACTION_ = '$ACTION_' -const $ACTION_REF_ = '$ACTION_REF_' -const $ACTION_ID_ = '$ACTION_ID_' +export class UnreachableServerReferenceError extends Error {} /** - * This function mirrors logic inside React's decodeAction and should be kept in - * sync with that. It pre-parses the FormData to ensure that all action IDs are - * reachable from the current route. - * Adapted from Next.js's pre-decode validation: - * https://github.com/vercel/next.js/blob/aae4179ac628e55483b62cd023a7e1827dcef122/packages/next/src/server/app-render/action-handler.ts#L1467-L1576 + * Rejects server references that the current route cannot reach while React + * decodes an action request, including server functions passed as arguments + * or bound to the action. */ -export function areAllActionIdsValid( - mpaFormData: FormData, +export function createServerReferenceValidator( pathname: string, -): boolean { +): ((metadata: { id: string; name: string }) => void) | undefined { if (!routeActionManifest) { - return true + return } - const reachableActionIds = routeActionManifest[pathname] ?? [] - let seenActionRefs = 0 - let hasAtLeastOneAction = false - // Before we attempt to decode the payload for a possible MPA action, assert that all - // action IDs are valid IDs. If not we should disregard the payload - for (const key of mpaFormData.keys()) { - if (!key.startsWith($ACTION_)) { - // not a relevant field - continue + return ({ id, name }) => { + if (!reachableActionIds.includes(`${id}#${name}`)) { + throw new UnreachableServerReferenceError(`${id}#${name}`) } - - if (key.startsWith($ACTION_ID_)) { - // No Bound args case - if (isInvalidActionIdFieldName(key, reachableActionIds)) { - return false - } - - hasAtLeastOneAction = true - } else if (key.startsWith($ACTION_REF_)) { - if (++seenActionRefs > 2) { - // We only expect to see at most 2 $ACTION_REF_ fields in the form data: - // one from
- // and one from - return false - } - - // Bound args case - const actionDescriptorField = - $ACTION_ + key.slice($ACTION_REF_.length) + ':0' - const actionFields = mpaFormData.getAll(actionDescriptorField) - if (actionFields.length !== 1) { - return false - } - const actionField = actionFields[0] - if (typeof actionField !== 'string') { - return false - } - - if (isInvalidStringActionDescriptor(actionField, reachableActionIds)) { - return false - } - hasAtLeastOneAction = true - } - } - return hasAtLeastOneAction -} - -const ACTION_DESCRIPTOR_ID_PREFIX = '{"id":"' -function isInvalidStringActionDescriptor( - actionDescriptor: string, - reachableActionIds: readonly string[], -): unknown { - if (actionDescriptor.startsWith(ACTION_DESCRIPTOR_ID_PREFIX) === false) { - return true } - - const from = ACTION_DESCRIPTOR_ID_PREFIX.length - const to = actionDescriptor.indexOf('"', from) - if (to === -1) { - return true - } - - // We expect actionDescriptor to be '{"id":"",...}' - const actionId = actionDescriptor.slice(from, to) - if (!reachableActionIds.includes(actionId)) { - return true - } - - return false -} - -function isInvalidActionIdFieldName( - actionIdFieldName: string, - reachableActionIds: readonly string[], -): boolean { - // The field name must always start with $ACTION_ID_ but since the action id - // is extracted from the key of the field we have already validated this - // before entering this function. - const actionId = actionIdFieldName.slice($ACTION_ID_.length) - if (!reachableActionIds.includes(actionId)) { - return true - } - - return false } diff --git a/packages/plugin-rsc/examples/action-reachability/src/framework/entry.rsc.tsx b/packages/plugin-rsc/examples/action-reachability/src/framework/entry.rsc.tsx index 7c580a8fb..3190aa43c 100644 --- a/packages/plugin-rsc/examples/action-reachability/src/framework/entry.rsc.tsx +++ b/packages/plugin-rsc/examples/action-reachability/src/framework/entry.rsc.tsx @@ -8,7 +8,11 @@ import { } from '@vitejs/plugin-rsc/rsc' import type { ReactFormState } from 'react-dom/client' import { getRoute, RouteRoot } from '../app/routes.tsx' -import { areAllActionIdsValid, routeActionRequest } from './action-routing.ts' +import { + routeActionRequest, + UnreachableServerReferenceError, + createServerReferenceValidator, +} from './action-routing.ts' import { parseRenderRequest } from './request.tsx' export type RscPayload = { @@ -35,6 +39,9 @@ async function handleRequest( let temporaryReferences: unknown | undefined let actionStatus: number | undefined if (renderRequest.isAction === true) { + const onServerReference = createServerReferenceValidator( + renderRequest.url.pathname, + ) if (renderRequest.actionId) { // Run the action through middleware for a page whose graph reaches it. const routing = routeActionRequest(renderRequest) @@ -49,7 +56,23 @@ async function handleRequest( ? await request.formData() : await request.text() temporaryReferences = createTemporaryReferenceSet() - const args = await decodeReply(body, { temporaryReferences }) + // Routing checked only the outer action ID. Server functions nested in + // the arguments are checked while React decodes them. + let args: unknown[] + try { + args = await decodeReply( + body, + { temporaryReferences }, + { onServerReference }, + ) + } catch (e) { + if (e instanceof UnreachableServerReferenceError) { + return unreachableServerReferenceResponse() + } + throw e + } + // `loadServerAction` doesn't call `onServerReference`, but routing has + // already accepted this ID for the current route. const action = await loadServerAction(renderRequest.actionId) try { const data = await action.apply(null, args) @@ -59,17 +82,39 @@ async function handleRequest( actionStatus = 500 } } else { + // Progressive forms carry no action ID header, so decoding is their only + // check. React resolves the action from a `$ACTION_ID_*` field, or for a + // bound action from `$ACTION_:0` metadata, whose bound values can be + // further server references. const formData = await request.formData() - if (!areAllActionIdsValid(formData, renderRequest.url.pathname)) { - return new Response('Server action is not reachable from this route', { - status: 404, + let decodedAction: () => Promise + try { + decodedAction = await decodeAction(formData, { onServerReference }) + } catch (e) { + if (e instanceof UnreachableServerReferenceError) { + return unreachableServerReferenceResponse() + } + throw e + } + let result: unknown + try { + result = await decodedAction() + } catch (e) { + return new Response('Internal Server Error: server action failed', { + status: 500, }) } - const decodedAction = await decodeAction(formData) try { - const result = await decodedAction() - formState = await decodeFormState(result, formData) + // React decodes here only for `useActionState` forms (`$ACTION_KEY`). + // Every server reference React resolves goes through the manifest, so + // the callback holds this decode to the same route as `decodeAction`. + formState = await decodeFormState(result, formData, { + onServerReference, + }) } catch (e) { + if (e instanceof UnreachableServerReferenceError) { + return unreachableServerReferenceResponse() + } return new Response('Internal Server Error: server action failed', { status: 500, }) @@ -110,6 +155,12 @@ async function handleRequest( }) } +function unreachableServerReferenceResponse() { + return new Response('Server action is not reachable from this route', { + status: 404, + }) +} + if (import.meta.hot) { import.meta.hot.accept() } diff --git a/packages/plugin-rsc/src/core/rsc.ts b/packages/plugin-rsc/src/core/rsc.ts index a4573a39e..dfc7405d8 100644 --- a/packages/plugin-rsc/src/core/rsc.ts +++ b/packages/plugin-rsc/src/core/rsc.ts @@ -97,9 +97,11 @@ export async function loadServerAction(id: string): Promise { return mod[name] } -export function createServerManifest(options?: { - preserveServerReferences?: boolean -}): BundlerConfig { +export function createServerManifest( + options?: CreateServerManifestOptions & { + preserveServerReferences?: boolean + }, +): BundlerConfig { const prefix = options?.preserveServerReferences ? SERVER_REFERENCE_PRESERVE_PREFIX : '' @@ -113,6 +115,7 @@ export function createServerManifest(options?: { let [id, name] = $$id.split('#') tinyassert(id) tinyassert(name) + options?.onServerReference?.({ id, name }) return { id: SERVER_REFERENCE_PREFIX + prefix + id + cacheTag, name, @@ -180,3 +183,17 @@ export interface ClientReferenceMetadata { export interface CreateClientManifestOptions { onClientReference?: (metadata: ClientReferenceMetadata) => void } + +export interface ServerReferenceMetadata { + id: string + name: string +} + +export interface CreateServerManifestOptions { + /** + * Called for each server reference React resolves while decoding. + * Throw to reject the reference before its module loads. + * @experimental + */ + onServerReference?: (metadata: ServerReferenceMetadata) => void +} diff --git a/packages/plugin-rsc/src/react/rsc/server.node.ts b/packages/plugin-rsc/src/react/rsc/server.node.ts index 98f046763..9c44f727b 100644 --- a/packages/plugin-rsc/src/react/rsc/server.node.ts +++ b/packages/plugin-rsc/src/react/rsc/server.node.ts @@ -5,10 +5,10 @@ import { createClientManifest, createServerManifest, type CreateClientManifestOptions, + type CreateServerManifestOptions, } from '../../core/rsc' import type { - DecodeReplyFromBusboyFunction, - DecodeReplyFunction, + DecodeReplyOptions, PipeableStream, RenderToPipeableStreamOptions, RenderToReadableStreamOptions, @@ -59,29 +59,48 @@ export const registerServerReference: ( name: string, ) => T = ReactServerNode.registerServerReference -export const decodeReply: DecodeReplyFunction = (body, options) => - ReactServerNode.decodeReply(body, createServerManifest(), options) +export function decodeReply( + body: string | FormData, + options?: DecodeReplyOptions, + extraOptions?: CreateServerManifestOptions, +): Promise { + return ReactServerNode.decodeReply( + body, + createServerManifest(extraOptions), + options, + ) +} -export const decodeReplyFromBusboy: DecodeReplyFromBusboyFunction = ( - body, - options, -) => - ReactServerNode.decodeReplyFromBusboy(body, createServerManifest(), options) +export function decodeReplyFromBusboy( + body: import('node:stream').Writable, // BusBoy instance + options?: DecodeReplyOptions, + extraOptions?: CreateServerManifestOptions, +): Promise { + return ReactServerNode.decodeReplyFromBusboy( + body, + createServerManifest(extraOptions), + options, + ) +} // TODO: Expose decodeReplyFromAsyncIterable with a shared Edge/Node type. -export function decodeAction(body: FormData): Promise<() => Promise> { - return ReactServerNode.decodeAction(body, createServerManifest()) +export function decodeAction( + body: FormData, + extraOptions?: CreateServerManifestOptions, +): Promise<() => Promise> { + return ReactServerNode.decodeAction(body, createServerManifest(extraOptions)) } export function decodeFormState( actionResult: unknown, body: FormData, + extraOptions?: CreateServerManifestOptions, ): Promise { return ReactServerNode.decodeFormState( actionResult, body, - createServerManifest(), + createServerManifest(extraOptions), ) } diff --git a/packages/plugin-rsc/src/react/rsc/server.ts b/packages/plugin-rsc/src/react/rsc/server.ts index 9136c0696..6a3117de6 100644 --- a/packages/plugin-rsc/src/react/rsc/server.ts +++ b/packages/plugin-rsc/src/react/rsc/server.ts @@ -5,9 +5,10 @@ import { createClientManifest, createServerManifest, type CreateClientManifestOptions, + type CreateServerManifestOptions, } from '../../core/rsc' import type { - DecodeReplyFunction, + DecodeReplyOptions, RenderToReadableStreamOptions, ServerTemporaryReferenceSet, } from '../../types' @@ -42,18 +43,35 @@ export const registerServerReference: ( name: string, ) => T = ReactServer.registerServerReference -export const decodeReply: DecodeReplyFunction = (body, options) => - ReactServer.decodeReply(body, createServerManifest(), options) +export function decodeReply( + body: string | FormData, + options?: DecodeReplyOptions, + extraOptions?: CreateServerManifestOptions, +): Promise { + return ReactServer.decodeReply( + body, + createServerManifest(extraOptions), + options, + ) +} -export function decodeAction(body: FormData): Promise<() => Promise> { - return ReactServer.decodeAction(body, createServerManifest()) +export function decodeAction( + body: FormData, + extraOptions?: CreateServerManifestOptions, +): Promise<() => Promise> { + return ReactServer.decodeAction(body, createServerManifest(extraOptions)) } export function decodeFormState( actionResult: unknown, body: FormData, + extraOptions?: CreateServerManifestOptions, ): Promise { - return ReactServer.decodeFormState(actionResult, body, createServerManifest()) + return ReactServer.decodeFormState( + actionResult, + body, + createServerManifest(extraOptions), + ) } export const createTemporaryReferenceSet: () => ServerTemporaryReferenceSet =