hono icon indicating copy to clipboard operation
hono copied to clipboard

make fetch Response headers mutable

Open nitedani opened this issue 1 year ago • 7 comments

The added test fails without the PR.

Fixes:

  • https://github.com/honojs/hono/issues/3316

  • [x] Add tests

  • [x] Run tests

  • [x] bun run format:fix && bun run lint:fix to format the code

nitedani avatar Aug 24 '24 13:08 nitedani

Hi @nitedani, Thanks for making the pull request!

Referencing internal symbols is a very interesting implementation, but in the main body of the hono we want to ensure that it is implemented only in runtime-independent code, within the scope of the web standard.

Also, as we want to maintain high performance with simple requests, we usually do not want to include processing for ‘immutable response objects’.

About #3316, I would prefer the following approach.

diff --git a/src/context.ts b/src/context.ts
index e3dbc3c5..487e1456 100644
--- a/src/context.ts
+++ b/src/context.ts
@@ -465,16 +465,32 @@ export class Context<
   set res(_res: Response | undefined) {
     this.#isFresh = false
     if (this.#res && _res) {
-      this.#res.headers.delete('content-type')
-      for (const [k, v] of this.#res.headers.entries()) {
-        if (k === 'set-cookie') {
-          const cookies = this.#res.headers.getSetCookie()
-          _res.headers.delete('set-cookie')
-          for (const cookie of cookies) {
-            _res.headers.append('set-cookie', cookie)
+      try {
+        for (const [k, v] of this.#res.headers.entries()) {
+          if (k === 'content-type') {
+            continue
           }
+
+          if (k === 'set-cookie') {
+            const cookies = this.#res.headers.getSetCookie()
+            _res.headers.delete('set-cookie')
+            for (const cookie of cookies) {
+              _res.headers.append('set-cookie', cookie)
+            }
+          } else {
+            _res.headers.set(k, v)
+          }
+        }
+      } catch (e) {
+        if (e instanceof TypeError && e.message.includes('immutable')) {
+          // `_res` is immutable (probably a response from a fetch API), so retry with a new response.
+          this.res = new Response(_res.body, {
+            headers: _res.headers,
+            status: _res.status,
+          })
+          return
         } else {
-          _res.headers.set(k, v)
+          throw e
         }
       }
     }

usualoma avatar Aug 24 '24 23:08 usualoma

I would prefer the following approach.

That fails the added test ☹️ I think we need to check if the response headers are immutable. Checking it is weird 😢 https://github.com/honojs/hono/pull/3318/files#diff-0cd594dddd6a9f2e3e26afebbb92716434437d7001c2416252aa7531e7f2b8d4R468-R480

Edge case: calling new Response(_res.body) throws an error if _res.body was already consumed. For example

const res = fetch(...)
await res.text()
ctx.res = res // TypeError: Response body object should not be disturbed or locked

nitedani avatar Aug 25 '24 00:08 nitedani

@nitedani

Thanks for the answer. I think the test needs to be modified. I don't think ‘c.res.headers becomes mutable when c.res = res’ is a result that context.ts should guarantee. The result that should be guaranteed is that c.res = res does not result in an error'.

    c.res = res
    c.res.headers.set('X-Custom', 'Message')
    expect(c.res.headers.get('X-Custom')).toBe('Message')

What is needed is a test of the following. However, it is debatable whether it is appropriate to use the external service https://jsonplaceholder.typicode.com in unit tests here.

  it('Should be able to overwrite a fetch reponse with a new response.', async () => {
    c.res = await fetch('https://jsonplaceholder.typicode.com/todos/1')
    c.res = new Response('foo', {
      headers: {
        'X-Custom': 'Message',
      },
    })
    expect(c.res.headers.get('X-Custom')).toBe('Message')
  })

  it('Should be able to overwrite a response with a fetch response.', async () => {
    c.res = new Response('foo', {
      headers: {
        'X-Custom': 'Message',
      },
    })
    c.res = await fetch('https://jsonplaceholder.typicode.com/todos/1')
    expect(c.res.headers.get('X-Custom')).toBe('Message')
  })

usualoma avatar Aug 25 '24 02:08 usualoma

The following cases are problems with the code itself. It is not possible to use the res after res.text() in hono (or any other framework) as response, so I think it is not a target to be handled by context.ts.

const res = fetch(...)
await res.text()
ctx.res = res

usualoma avatar Aug 25 '24 02:08 usualoma

Codecov Report

All modified and coverable lines are covered by tests :white_check_mark:

Project coverage is 95.77%. Comparing base (d1c7f6f) to head (00d4bf1). Report is 29 commits behind head on next.

Additional details and impacted files
@@            Coverage Diff             @@
##             next    #3318      +/-   ##
==========================================
- Coverage   96.31%   95.77%   -0.54%     
==========================================
  Files         151      152       +1     
  Lines       15368     9201    -6167     
  Branches     2693     2822     +129     
==========================================
- Hits        14801     8812    -5989     
+ Misses        567      389     -178     

:umbrella: View full report in Codecov by Sentry.
:loudspeaker: Have feedback on the report? Share it here.

codecov[bot] avatar Aug 25 '24 02:08 codecov[bot]

. I don't think ‘c.res.headers becomes mutable when c.res = res’ is a result that context.ts should guarantee.

I agree. Should we close the PR? Maybe we can support middleware like compression for fetch responses some other way?

nitedani avatar Aug 27 '24 20:08 nitedani

Hi @nitedani, Thanks for your comment.

The following branch is the one with tests added to the contents of https://github.com/honojs/hono/pull/3318#issuecomment-2308567803.

https://github.com/honojs/hono/compare/main...usualoma:hono:fix/update-context-res

As for #3316, I believe this is how it should be fixed.

It is up to you whether you want to reflect this in this pull request or close this pull request once and make another pull request.

usualoma avatar Aug 28 '24 13:08 usualoma

Hi @nitedani @usualoma

Sorry for the delayed response. This will be awesome PR! Sometimes, I have trouble with a matter like #3316 when I fetch origin content and return it in CDN edges.

The code @usualoma wrote https://github.com/honojs/hono/pull/3318#issuecomment-2308567803 and https://github.com/honojs/hono/pull/3318#issuecomment-2315331269 looks good. Would you like us to go with it?

yusukebe avatar Aug 29 '24 07:08 yusukebe

Hi @nitedani

Can you work on this? If you can't, I'll do it.

yusukebe avatar Sep 05 '24 07:09 yusukebe

@yusukebe Please take it.

nitedani avatar Sep 05 '24 13:09 nitedani

Hi @usualoma

I've updated the @nitedani 's branch with applying your patch and pushing it. The test passed on my machine, but the CI is falling. Do you have any idea?

yusukebe avatar Sep 06 '24 12:09 yusukebe

@yusukebe OK, mystery solved. Since we test against the branch being merged, we need to take into account the current contents of main.

CleanShot 2024-09-06 at 22 26 16@2x

With the changes in #3317, small data is no longer compressed, so the test needs to be large as well. https://github.com/honojs/hono/blob/main/src/middleware/compress/index.ts#L36

diff --git a/runtime_tests/node/index.test.ts b/runtime_tests/node/index.test.ts
index b55512d5..310ec9c7 100644
--- a/runtime_tests/node/index.test.ts
+++ b/runtime_tests/node/index.test.ts
@@ -207,10 +207,11 @@ describe('streamSSE', () => {
 })
 
 describe('compress', async () => {
+  const cssContent = Array.from({ length: 60 }, () => 'body { color: red; }').join('\n')
   const [externalServer, externalPort] = await new Promise<[Server, number]>((resolve) => {
     const externalApp = new Hono()
     externalApp.get('/style.css', (c) =>
-      c.text('body { color: red; }', {
+      c.text(cssContent, {
         headers: {
           'Content-Type': 'text/css',
         },
 
@@ -242,6 +243,6 @@ describe('compress', async () => {
     const res = await request(server).get('/fetch/style.css')
     expect(res.status).toBe(200)
     expect(res.headers['content-encoding']).toBe('gzip')
-    expect(res.text).toBe('body { color: red; }')
+    expect(res.text).toBe(cssContent)
   })
 })

usualoma avatar Sep 06 '24 13:09 usualoma

@usualoma

Cooool! It passed. Can you review this again, though it should be okay?

yusukebe avatar Sep 06 '24 14:09 yusukebe

@yusukebe Thank you, LGTM!

usualoma avatar Sep 06 '24 22:09 usualoma