Skip to content
Open
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
1 change: 1 addition & 0 deletions packages/next/src/exports/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export { GRAPHQL_PLAYGROUND_GET, GRAPHQL_POST } from '../routes/graphql/index.js
export {
DELETE as REST_DELETE,
GET as REST_GET,
HEAD as REST_HEAD,
OPTIONS as REST_OPTIONS,
PATCH as REST_PATCH,
POST as REST_POST,
Expand Down
2 changes: 2 additions & 0 deletions packages/next/src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ export { GRAPHQL_PLAYGROUND_GET, GRAPHQL_POST } from './graphql/index.js'
export {
DELETE as REST_DELETE,
GET as REST_GET,
HEAD as REST_HEAD,
OPTIONS as REST_OPTIONS,
PATCH as REST_PATCH,
POST as REST_POST,
PUT as REST_PUT,
} from './rest/index.js'
2 changes: 2 additions & 0 deletions packages/next/src/routes/rest/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ export const OPTIONS = handlerBuilder

export const GET = handlerBuilder

export const HEAD = handlerBuilder

export const POST = handlerBuilder

export const DELETE = handlerBuilder
Expand Down
62 changes: 42 additions & 20 deletions packages/payload/src/utilities/handleEndpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,31 +216,45 @@ export const handleEndpoints = async ({
)
}

// Find the relevant endpoint configuration
const endpoint = endpoints?.find((endpoint) => {
if (endpoint.method !== req.method?.toLowerCase()) {
return false
}
const reqMethod = req.method?.toLowerCase()

const pathMatchFn = match(endpoint.path, { decode: decodeURIComponent })
const findEndpoint = (targetMethod: string) =>
endpoints?.find((endpoint) => {
if (endpoint.method !== targetMethod) {
return false
}

const matchResult = pathMatchFn(adjustedPathname)
const pathMatchFn = match(endpoint.path, { decode: decodeURIComponent })

if (!matchResult) {
return false
}
const matchResult = pathMatchFn(adjustedPathname)

req.routeParams = matchResult.params as Record<string, unknown>
if (!matchResult) {
return false
}

// Inject to routeParams the slug as well so it can be used later
if (collection) {
req.routeParams.collection = collection.config.slug
} else if (globalConfig) {
req.routeParams.global = globalConfig.slug
}
req.routeParams = matchResult.params as Record<string, unknown>

return true
})
// Inject to routeParams the slug as well so it can be used later
if (collection) {
req.routeParams.collection = collection.config.slug
} else if (globalConfig) {
req.routeParams.global = globalConfig.slug
}

return true
})

// Find the relevant endpoint configuration
let endpoint = findEndpoint(reqMethod!)

// Per RFC 9110 §9.3.2, HEAD must behave identically to GET except that the
// server must not send a response body. Fall back to the GET endpoint so that
// HEAD /file/:filename returns the same status and headers as GET.
let isHeadViaGetFallback = false
if (!endpoint && reqMethod === 'head') {
endpoint = findEndpoint('get')
isHeadViaGetFallback = Boolean(endpoint)
}

if (endpoint) {
handler = endpoint.handler
Expand All @@ -267,7 +281,15 @@ export const handleEndpoints = async ({

const response = await handler(req)

return new Response(response.body, {
// For HEAD requests resolved via the GET fallback, cancel the body stream
// (e.g. an open fs.ReadStream) so the file handle is released immediately,
// then return an empty body — headers and status are preserved as required
// by RFC 9110 §9.3.2.
if (isHeadViaGetFallback && response.body) {
await response.body.cancel()
}

return new Response(isHeadViaGetFallback ? null : response.body, {
headers: headersWithCors({
headers: mergeHeaders(req.responseHeaders ?? new Headers(), response.headers),
req,
Expand Down
23 changes: 23 additions & 0 deletions test/__helpers/shared/NextRESTClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
REST_DELETE as createDELETE,
REST_GET as createGET,
GRAPHQL_POST as createGraphqlPOST,
REST_HEAD as createHEAD,
REST_PATCH as createPATCH,
REST_POST as createPOST,
REST_PUT as createPUT,
Expand Down Expand Up @@ -59,6 +60,11 @@ export class NextRESTClient {

private _GRAPHQL_POST: (request: Request) => Promise<Response>

private _HEAD: (
request: Request,
args: { params: Promise<{ slug: string[] }> },
) => Promise<Response>

private _PATCH: (
request: Request,
args: { params: Promise<{ slug: string[] }> },
Expand Down Expand Up @@ -86,6 +92,7 @@ export class NextRESTClient {
this.serverURL = config.serverURL
}
this._GET = createGET(config)
this._HEAD = createHEAD(config)
this._POST = createPOST(config)
this._DELETE = createDELETE(config)
this._PATCH = createPATCH(config)
Expand Down Expand Up @@ -165,6 +172,22 @@ export class NextRESTClient {
return this._GET(request, { params: Promise.resolve({ slug }) })
}

async HEAD(
path: ValidPath,
options: Omit<RequestInit, 'body'> & RequestOptions = {},
): Promise<Response> {
const { slug, params, url } = this.generateRequestParts(path)
const { query, ...rest } = options || {}
const queryParams = generateQueryString(query, params)

const request = new Request(`${url}${queryParams}`, {
...rest,
headers: this.buildHeaders(options),
method: 'HEAD',
})
return this._HEAD(request, { params: Promise.resolve({ slug }) })
}

async GRAPHQL_POST(options: RequestInit & RequestOptions): Promise<Response> {
const { query, ...rest } = options
const queryParams = generateQueryString(query, {})
Expand Down
19 changes: 19 additions & 0 deletions test/uploads/int.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -715,6 +715,25 @@ describe('Collections - Uploads', () => {

await payload.delete({ collection: mediaSlug, id: mediaDoc.id })
})

it('should handle HEAD request for media file returning identical status and content-type', async () => {
const filePath = path.resolve(dirname, './image.png')
const file = await getFileByPath(filePath)
file.name = 'head-test.png'

const mediaDoc = (await payload.create({
collection: mediaSlug,
data: {},
file,
})) as unknown as Media

const response = await restClient.HEAD(`/${mediaSlug}/file/${mediaDoc.filename}`)

expect(response.status).toBe(200)
expect(response.headers.get('content-type')).toContain('image/png')

await payload.delete({ collection: mediaSlug, id: mediaDoc.id })
})
})
})

Expand Down
Loading