Skip to content

Commit

Permalink
update status codes for redirect and permanentRedirect in action …
Browse files Browse the repository at this point in the history
…handlers
  • Loading branch information
ztanner committed Nov 27, 2023
1 parent 8079b48 commit 4ff321d
Show file tree
Hide file tree
Showing 10 changed files with 136 additions and 18 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description: API Reference for the permanentRedirect function.

The `permanentRedirect` function allows you to redirect the user to another URL. `permanentRedirect` can be used in Server Components, Client Components, [Route Handlers](/docs/app/building-your-application/routing/route-handlers), and [Server Actions](/docs/app/building-your-application/data-fetching/forms-and-mutations).

When used in a streaming context, this will insert a meta tag to emit the redirect on the client side. Otherwise, it will serve a 308 (Permanent) HTTP redirect response to the caller.
When used in a streaming context, this will insert a meta tag to emit the redirect on the client side. When used in a server action, it will serve a 301 (Permanent) HTTP redirect response to the caller. Otherwise, it will serve a 308 (Permanent) HTTP redirect response to the caller.

If a resource doesn't exist, you can use the [`notFound` function](/docs/app/api-reference/functions/not-found) instead.

Expand Down
2 changes: 1 addition & 1 deletion docs/02-app/02-api-reference/04-functions/redirect.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description: API Reference for the redirect function.

The `redirect` function allows you to redirect the user to another URL. `redirect` can be used in Server Components, Client Components, [Route Handlers](/docs/app/building-your-application/routing/route-handlers), and [Server Actions](/docs/app/building-your-application/data-fetching/forms-and-mutations).

When used in a [streaming context](/docs/app/building-your-application/routing/loading-ui-and-streaming#what-is-streaming), this will insert a meta tag to emit the redirect on the client side. Otherwise, it will serve a 307 HTTP redirect response to the caller.
When used in a [streaming context](/docs/app/building-your-application/routing/loading-ui-and-streaming#what-is-streaming), this will insert a meta tag to emit the redirect on the client side. When used in a server action, it will serve a 302 (Found) HTTP redirect response to the caller. Otherwise, it will serve a 307 HTTP redirect response to the caller.

If a resource doesn't exist, you can use the [`notFound` function](/docs/app/api-reference/functions/not-found) instead.

Expand Down
50 changes: 38 additions & 12 deletions packages/next/src/client/components/redirect.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { requestAsyncStorage } from './request-async-storage.external'
import type { ResponseCookies } from '../../server/web/spec-extension/cookies'
import { actionAsyncStorage } from './action-async-storage.external'
import { RedirectStatusCode } from '../../shared/lib/constants'

const REDIRECT_ERROR_CODE = 'NEXT_REDIRECT'

Expand All @@ -9,17 +11,17 @@ export enum RedirectType {
}

export type RedirectError<U extends string> = Error & {
digest: `${typeof REDIRECT_ERROR_CODE};${RedirectType};${U};${boolean}`
digest: `${typeof REDIRECT_ERROR_CODE};${RedirectType};${U};${RedirectStatusCode};`
mutableCookies: ResponseCookies
}

export function getRedirectError(
url: string,
type: RedirectType,
permanent: boolean = false
statusCode: RedirectStatusCode = RedirectStatusCode.TemporaryRedirect
): RedirectError<typeof url> {
const error = new Error(REDIRECT_ERROR_CODE) as RedirectError<typeof url>
error.digest = `${REDIRECT_ERROR_CODE};${type};${url};${permanent}`
error.digest = `${REDIRECT_ERROR_CODE};${type};${url};${statusCode};`
const requestStore = requestAsyncStorage.getStore()
if (requestStore) {
error.mutableCookies = requestStore.mutableCookies
Expand All @@ -30,29 +32,49 @@ export function getRedirectError(
/**
* When used in a streaming context, this will insert a meta tag to
* redirect the user to the target page. When used in a custom app route, it
* will serve a 307 to the caller.
* will serve a 307/302 to the caller.
*
* @param url the url to redirect to
*/
export function redirect(
url: string,
type: RedirectType = RedirectType.replace
): never {
throw getRedirectError(url, type, false)
const actionStore = actionAsyncStorage.getStore()
throw getRedirectError(
url,
type,
// If we're in an action, we want to use a 302 redirect
// as we don't want the POST request to follow the redirect,
// as it could result in erroneous re-submissions.
actionStore?.isAction
? RedirectStatusCode.Found
: RedirectStatusCode.TemporaryRedirect
)
}

/**
* When used in a streaming context, this will insert a meta tag to
* redirect the user to the target page. When used in a custom app route, it
* will serve a 308 to the caller.
* will serve a 308/301 to the caller.
*
* @param url the url to redirect to
*/
export function permanentRedirect(
url: string,
type: RedirectType = RedirectType.replace
): never {
throw getRedirectError(url, type, true)
const actionStore = actionAsyncStorage.getStore()
throw getRedirectError(
url,
type,
// If we're in an action, we want to use a 301 redirect
// as we don't want the POST request to follow the redirect,
// as it could result in erroneous re-submissions.
actionStore?.isAction
? RedirectStatusCode.MovedPermanently
: RedirectStatusCode.PermanentRedirect
)
}

/**
Expand All @@ -67,15 +89,19 @@ export function isRedirectError<U extends string>(
): error is RedirectError<U> {
if (typeof error?.digest !== 'string') return false

const [errorCode, type, destination, permanent] = (
error.digest as string
).split(';', 4)
const [errorCode, type, destination, status] = (error.digest as string).split(
';',
4
)

const statusCode = Number(status)

return (
errorCode === REDIRECT_ERROR_CODE &&
(type === 'replace' || type === 'push') &&
typeof destination === 'string' &&
(permanent === 'true' || permanent === 'false')
!isNaN(statusCode) &&
statusCode in RedirectStatusCode
)
}

Expand Down Expand Up @@ -114,5 +140,5 @@ export function getRedirectStatusCodeFromError<U extends string>(
throw new Error('Not a redirect error')
}

return error.digest.split(';', 4)[3] === 'true' ? 308 : 307
return Number(error.digest.split(';', 4)[3])
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { requestAsyncStorage } from '../../../../client/components/request-async
import { staticGenerationAsyncStorage } from '../../../../client/components/static-generation-async-storage.external'
import { actionAsyncStorage } from '../../../../client/components/action-async-storage.external'
import * as sharedModules from './shared-modules'
import { getIsServerAction } from '../../../lib/server-action-request-meta'

/**
* The AppRouteModule is the type of the module exported by the bundled App
Expand Down Expand Up @@ -274,6 +275,7 @@ export class AppRouteRouteModule extends RouteModule<
const response: unknown = await this.actionAsyncStorage.run(
{
isAppRoute: true,
isAction: getIsServerAction(request),
},
() =>
RequestAsyncStorageWrapper.wrap(
Expand Down
14 changes: 10 additions & 4 deletions packages/next/src/server/lib/server-action-request-meta.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type { IncomingMessage } from 'http'
import type { BaseNextRequest } from '../base-http'
import type { NextRequest } from '../web/exports'
import { ACTION } from '../../client/components/app-router-headers'

export function getServerActionRequestMetadata(
req: IncomingMessage | BaseNextRequest
req: IncomingMessage | BaseNextRequest | NextRequest
): {
actionId: string | null
isURLEncodedAction: boolean
Expand All @@ -13,8 +14,13 @@ export function getServerActionRequestMetadata(
let actionId: string | null
let contentType: string | null

actionId = (req.headers[ACTION.toLowerCase()] as string) ?? null
contentType = req.headers['content-type'] ?? null
if (req.headers instanceof Headers) {
actionId = req.headers.get(ACTION.toLowerCase()) ?? null
contentType = req.headers.get('content-type')
} else {
actionId = (req.headers[ACTION.toLowerCase()] as string) ?? null
contentType = req.headers['content-type'] ?? null
}

const isURLEncodedAction = Boolean(
req.method === 'POST' && contentType === 'application/x-www-form-urlencoded'
Expand All @@ -32,7 +38,7 @@ export function getServerActionRequestMetadata(
}

export function getIsServerAction(
req: IncomingMessage | BaseNextRequest
req: IncomingMessage | BaseNextRequest | NextRequest
): boolean {
const { isFetchAction, isURLEncodedAction, isMultipartAction } =
getServerActionRequestMetadata(req)
Expand Down
7 changes: 7 additions & 0 deletions packages/next/src/shared/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,3 +156,10 @@ export const SYSTEM_ENTRYPOINTS = new Set<string>([
CLIENT_STATIC_FILES_RUNTIME_AMP,
CLIENT_STATIC_FILES_RUNTIME_MAIN_APP,
])

export enum RedirectStatusCode {
MovedPermanently = 301,
Found = 302,
TemporaryRedirect = 307,
PermanentRedirect = 308,
}
49 changes: 49 additions & 0 deletions test/e2e/app-dir/actions/app-action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -858,5 +858,54 @@ createNextDescribe(
expect(html).not.toContain('qwerty123')
})
})

describe('redirects', () => {
it('redirects properly when server action handler uses `redirect`', async () => {
const postRequests = []

const browser = await next.browser('/redirects', {
beforePageLoad(page) {
page.on('request', (request) => {
const url = new URL(request.url())
if (
url.pathname.includes('/api-') &&
request.method() === 'POST'
) {
postRequests.push(url.pathname)
}
})
},
})
await browser.elementById('submit-api-redirect').click()
await check(() => browser.url(), /success=true/)

// verify that the POST request was only made to the action handler
expect(postRequests).toEqual(['/redirects/api-redirect'])
})

it('redirects properly when server action handler uses `permanentRedirect`', async () => {
const postRequests = []

const browser = await next.browser('/redirects', {
beforePageLoad(page) {
page.on('request', (request) => {
const url = new URL(request.url())
if (
url.pathname.includes('/api-') &&
request.method() === 'POST'
) {
postRequests.push(url.pathname)
}
})
},
})

await browser.elementById('submit-api-redirect-permanent').click()
await check(() => browser.url(), /success=true/)

// verify that the POST request was only made to the action handler
expect(postRequests).toEqual(['/redirects/api-redirect-permanent'])
})
})
}
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { permanentRedirect } from 'next/navigation'

export function POST(request) {
permanentRedirect('/redirects/?success=true')
}
5 changes: 5 additions & 0 deletions test/e2e/app-dir/actions/app/redirects/api-redirect/route.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation'

export function POST(request) {
redirect('/redirects/?success=true')
}
18 changes: 18 additions & 0 deletions test/e2e/app-dir/actions/app/redirects/page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export default function Home() {
return (
<main id="redirect-page">
<h1>POST /api-redirect (`redirect()`)</h1>
<form action="/redirects/api-redirect" method="POST">
<input type="submit" value="Submit" id="submit-api-redirect" />
</form>
<h1>POST /api-redirect-permanent (`permanentRedirect()`)</h1>
<form action="/redirects/api-redirect-permanent" method="POST">
<input
type="submit"
value="Submit"
id="submit-api-redirect-permanent"
/>
</form>
</main>
)
}

0 comments on commit 4ff321d

Please sign in to comment.