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
39 changes: 39 additions & 0 deletions src/context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,29 @@ describe('Context', () => {
expect(foo).toBe('Bar, Buzz')
})

it('c.body() - content-type cannot be overridden by the default response when append headers', async () => {
c.header('Vary', 'Accept-Encoding', { append: true })
c.res
c.header('Content-Type', 'text/html')
const res = c.body('<h1>Hi</h1>')
expect(res.headers.get('Content-Type')).toMatch('text/html')
})

it('c.body() - content-type can set explicitly via c.res.headers', async () => {
c.header('Vary', 'Accept-Encoding', { append: true })
c.res.headers.set('Content-Type', 'text/html')
const res = c.body('<h1>Hi</h1>')
expect(res.headers.get('Content-Type')).toMatch('text/html')
})

it('c.body() - Different header settings require ensuring order', async () => {
c.header('Vary', 'Accept-Encoding', { append: true })
c.header('Content-Type', 'image/png')
c.res.headers.set('Content-Type', 'text/html')
const res = c.body('<h1>Hi</h1>')
expect(res.headers.get('Content-Type')).toMatch('text/html')
})

it('c.status()', async () => {
c.status(201)
const res = c.body('Hi')
Expand Down Expand Up @@ -418,6 +441,22 @@ describe('Context header', () => {
c.header('X-Custom', 'Message')
expect(c.res.headers.get('X-Custom')).toBe('Message')
})

it('Should handle headers with array values correctly', async () => {
c.header('X-Array', 'value1')
const res = c.json({ test: 'data' }, 200, {
'X-Array': ['new1', 'new2'],
})
expect(res.headers.get('X-Array')).toBe('new1, new2')
})

it('Should remove existing header when new value is empty array', async () => {
c.header('X-Test', 'existing')
const res = c.json({ test: 'data' }, 200, {
'X-Test': [],
})
expect(res.headers.get('X-Test')).toBeNull()
})
})

describe('Pass a ResponseInit to respond methods', () => {
Expand Down
202 changes: 55 additions & 147 deletions src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,18 +273,11 @@ type ResponseOrInit<T extends StatusCode = StatusCode> = ResponseInit<T> | Respo

export const TEXT_PLAIN = 'text/plain; charset=UTF-8'

/**
* Sets the headers of a response.
*
* @param headers - The Headers object to set the headers on.
* @param map - A record of header key-value pairs to set.
* @returns The updated Headers object.
*/
const setHeaders = (headers: Headers, map: Record<string, string> = {}) => {
for (const key of Object.keys(map)) {
headers.set(key, map[key])
const setDefaultContentType = (contentType: string, headers?: HeaderRecord): HeaderRecord => {
return {
'Content-Type': contentType,
...headers,
}
return headers
}

export class Context<
Expand Down Expand Up @@ -329,15 +322,13 @@ export class Context<
*/
error: Error | undefined

#status: StatusCode = 200
#status: StatusCode | undefined
#executionCtx: FetchEventLike | ExecutionContext | undefined
#headers: Headers | undefined
#preparedHeaders: Record<string, string> | undefined
#res: Response | undefined
#isFresh = true
#layout: Layout<PropsForRenderer & { Layout: Layout }> | undefined
#renderer: Renderer | undefined
#notFoundHandler: NotFoundHandler<E> | undefined
#preparedHeaders: Headers | undefined

#matchResult: Result<[H, RouterRoute]> | undefined
#path: string | undefined
Expand Down Expand Up @@ -400,8 +391,9 @@ export class Context<
* The Response object for the current request.
*/
get res(): Response {
this.#isFresh = false
return (this.#res ||= new Response('404 Not Found', { status: 404 }))
return (this.#res ||= new Response(null, {
headers: (this.#preparedHeaders ??= new Headers()),
}))
}

/**
Expand All @@ -410,7 +402,6 @@ export class Context<
* @param _res - The Response object to set.
*/
set res(_res: Response | undefined) {
this.#isFresh = false
if (this.#res && _res) {
_res = new Response(_res.body, _res)
for (const [k, v] of this.#res.headers.entries()) {
Expand Down Expand Up @@ -515,46 +506,17 @@ export class Context<
if (this.finalized) {
this.#res = new Response((this.#res as Response).body, this.#res)
}
// Clear the header
const headers = this.#res ? this.#res.headers : (this.#preparedHeaders ??= new Headers())
if (value === undefined) {
if (this.#headers) {
this.#headers.delete(name)
} else if (this.#preparedHeaders) {
delete this.#preparedHeaders[name.toLocaleLowerCase()]
}
if (this.finalized) {
this.res.headers.delete(name)
}
return
}

if (options?.append) {
if (!this.#headers) {
this.#isFresh = false
this.#headers = new Headers(this.#preparedHeaders)
this.#preparedHeaders = {}
}
this.#headers.append(name, value)
headers.delete(name)
} else if (options?.append) {
headers.append(name, value)
} else {
if (this.#headers) {
this.#headers.set(name, value)
} else {
this.#preparedHeaders ??= {}
this.#preparedHeaders[name.toLowerCase()] = value
}
}

if (this.finalized) {
if (options?.append) {
this.res.headers.append(name, value)
} else {
this.res.headers.set(name, value)
}
headers.set(name, value)
}
}

status = (status: StatusCode): void => {
this.#isFresh = false
this.#status = status
}

Expand Down Expand Up @@ -634,65 +596,36 @@ export class Context<
arg?: StatusCode | ResponseOrInit,
headers?: HeaderRecord
): Response {
// Optimized
if (this.#isFresh && !headers && !arg && this.#status === 200) {
return new Response(data, {
headers: this.#preparedHeaders,
})
}

if (arg && typeof arg !== 'number') {
const header = new Headers(arg.headers)
if (this.#headers) {
// If the header is set by c.header() and arg.headers, c.header() will be prioritized.
this.#headers.forEach((v, k) => {
if (k === 'set-cookie') {
header.append(k, v)
} else {
header.set(k, v)
}
})
}
const headers = setHeaders(header, this.#preparedHeaders)
return new Response(data, {
headers,
status: arg.status ?? this.#status,
})
}

const status = typeof arg === 'number' ? arg : this.#status
this.#preparedHeaders ??= {}

this.#headers ??= new Headers()
setHeaders(this.#headers, this.#preparedHeaders)

if (this.#res) {
this.#res.headers.forEach((v, k) => {
if (k === 'set-cookie') {
this.#headers?.append(k, v)
const responseHeaders = this.#res
? new Headers(this.#res.headers)
: this.#preparedHeaders ?? new Headers()

if (typeof arg === 'object' && 'headers' in arg) {
const argHeaders = arg.headers instanceof Headers ? arg.headers : new Headers(arg.headers)
for (const [key, value] of argHeaders) {
if (key.toLowerCase() === 'set-cookie') {
responseHeaders.append(key, value)
} else {
this.#headers?.set(k, v)
responseHeaders.set(key, value)
}
})
setHeaders(this.#headers, this.#preparedHeaders)
}
}

headers ??= {}
for (const [k, v] of Object.entries(headers)) {
if (typeof v === 'string') {
this.#headers.set(k, v)
} else {
this.#headers.delete(k)
for (const v2 of v) {
this.#headers.append(k, v2)
if (headers) {
for (const [k, v] of Object.entries(headers)) {
if (typeof v === 'string') {
responseHeaders.set(k, v)
} else {
responseHeaders.delete(k)
for (const v2 of v) {
responseHeaders.append(k, v2)
}
}
}
}

return new Response(data, {
status,
headers: this.#headers,
})
const status = typeof arg === 'number' ? arg : arg?.status ?? this.#status
return new Response(data, { status, headers: responseHeaders })
}

newResponse: NewResponse = (...args) => this.#newResponse(...(args as Parameters<NewResponse>))
Expand Down Expand Up @@ -722,11 +655,7 @@ export class Context<
data: Data | null,
arg?: StatusCode | RequestInit,
headers?: HeaderRecord
): ReturnType<BodyRespond> => {
return (
typeof arg === 'number' ? this.#newResponse(data, arg, headers) : this.#newResponse(data, arg)
) as ReturnType<BodyRespond>
}
): ReturnType<BodyRespond> => this.#newResponse(data, arg, headers) as ReturnType<BodyRespond>

/**
* `.text()` can render text as `Content-Type:text/plain`.
Expand All @@ -745,22 +674,13 @@ export class Context<
arg?: ContentfulStatusCode | ResponseOrInit,
headers?: HeaderRecord
): ReturnType<TextRespond> => {
// If the header is empty, return Response immediately.
// Content-Type will be added automatically as `text/plain`.
if (!this.#preparedHeaders) {
if (this.#isFresh && !headers && !arg) {
// @ts-expect-error `Response` due to missing some types-only keys
return new Response(text)
}
this.#preparedHeaders = {}
}
this.#preparedHeaders['content-type'] = TEXT_PLAIN
if (typeof arg === 'number') {
// @ts-expect-error `Response` due to missing some types-only keys
return this.#newResponse(text, arg, headers)
}
// @ts-expect-error `Response` due to missing some types-only keys
return this.#newResponse(text, arg)
return !this.#preparedHeaders && !this.#status && !arg && !headers && !this.finalized
? (new Response(text) as ReturnType<TextRespond>)
: (this.#newResponse(
text,
arg,
setDefaultContentType(TEXT_PLAIN, headers)
) as ReturnType<TextRespond>)
}

/**
Expand All @@ -783,34 +703,23 @@ export class Context<
arg?: U | ResponseOrInit<U>,
headers?: HeaderRecord
): JSONRespondReturn<T, U> => {
const body = JSON.stringify(object)
this.#preparedHeaders ??= {}
this.#preparedHeaders['content-type'] = 'application/json'
/* eslint-disable @typescript-eslint/no-explicit-any */
return (
typeof arg === 'number' ? this.#newResponse(body, arg, headers) : this.#newResponse(body, arg)
) as any
return this.#newResponse(
JSON.stringify(object),
arg,
setDefaultContentType('application/json', headers)
) /* eslint-disable @typescript-eslint/no-explicit-any */ as any
}

html: HTMLRespond = (
html: string | Promise<string>,
arg?: ContentfulStatusCode | ResponseOrInit<ContentfulStatusCode>,
headers?: HeaderRecord
): Response | Promise<Response> => {
this.#preparedHeaders ??= {}
this.#preparedHeaders['content-type'] = 'text/html; charset=UTF-8'

if (typeof html === 'object') {
return resolveCallback(html, HtmlEscapedCallbackPhase.Stringify, false, {}).then((html) => {
return typeof arg === 'number'
? this.#newResponse(html, arg, headers)
: this.#newResponse(html, arg)
})
}

return typeof arg === 'number'
? this.#newResponse(html as string, arg, headers)
: this.#newResponse(html as string, arg)
const res = (html: string) =>
this.#newResponse(html, arg, setDefaultContentType('text/html; charset=UTF-8', headers))
return typeof html === 'object'
? resolveCallback(html, HtmlEscapedCallbackPhase.Stringify, false, {}).then(res)
: res(html)
}

/**
Expand All @@ -832,8 +741,7 @@ export class Context<
location: string | URL,
status?: T
): Response & TypedResponse<undefined, T, 'redirect'> => {
this.#headers ??= new Headers()
this.#headers.set('Location', String(location))
this.header('Location', String(location))
return this.newResponse(null, status ?? 302) as any
}

Expand Down
Loading