kwhitley / itty-router

A little router.
MIT License
1.7k stars 77 forks source link

request.headers seems to be empty on Cloudflare #168

Closed arthuredelstein closed 1 year ago

arthuredelstein commented 1 year ago

Describe the Issue

I want to return request.headers, and this works in Node.js, but it doesn't work in Cloudflare.

Example Router Code

import { 
  error,              // creates error Responses
  json,               // creates JSON Responses
  Router,             // the Router itself
} from 'itty-router'

const router = Router();

router.get('/headers', (req) => {
  return json(req.headers);
});

router.all('*', () => error(404));

const isNode = () => (typeof process !== 'undefined') &&
                    (process.release.name === 'node');

export default {
  fetch: (req, env, ctx) => router
                              .handle(req, env, ctx)
                              .catch(error)
}

if (isNode()) {
  (async () => {
    const { createServerAdapter } = await import('@whatwg-node/server');
    const { createServer } = await import('http');

    const ittyServer = createServerAdapter(
      (...args) => router
        .handle(...args)
        .then(json)
        .catch(error)
    );

    // Then use it in any environment
    const httpServer = createServer(ittyServer);
    httpServer.listen(3001);
    console.log('listening at https://localhost:3001');
  })();
}

Steps to Reproduce

Steps to reproduce the behavior:

  1. Add node_compat = true to wrangler.toml.
  2. Run code, one of: a. node src/worker.js b. wrangler dev c. wrangler dev --remote d. wrangler deploy
  3. Browse to https://[...]/headers
  4. Look at response in page

Expected Behavior

A JSON object with a number of header key-values. For example, with node.js I see:

{"headersInit":{"host":"127.0.0.1:3001","connection":"keep-alive","sec-ch-ua":"\"Brave\";v=\"113\", \"Chromium\";v=\"113\", \"Not-A.Brand\";v=\"24\"","sec-ch-ua-mobile":"?0","sec-ch-ua-platform":"\"macOS\"","upgrade-insecure-requests":"1","user-agent":"...","accept":"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8","sec-gpc":"1","accept-language":"en-US,en","sec-fetch-site":"none","sec-fetch-mode":"navigate","sec-fetch-user":"?1","sec-fetch-dest":"document","accept-encoding":"gzip, deflate, br","cookie":"secret=qwer"},"map":{},"mapIsBuilt":false,"objectNormalizedKeysOfHeadersInit":["host","connection","sec-ch-ua","sec-ch-ua-mobile","sec-ch-ua-platform","upgrade-insecure-requests","user-agent","accept","sec-gpc","accept-language","sec-fetch-site","sec-fetch-mode","sec-fetch-user","sec-fetch-dest","accept-encoding","cookie"],"objectOriginalKeysOfHeadersInit":["host","connection","sec-ch-ua","sec-ch-ua-mobile","sec-ch-ua-platform","upgrade-insecure-requests","user-agent","accept","sec-gpc","accept-language","sec-fetch-site","sec-fetch-mode","sec-fetch-user","sec-fetch-dest","accept-encoding","cookie"]}

Actual Behavior

Node.js gives results as expected. On Cloudflare, the page displays only an empty JSON object, {}.

kwhitley commented 1 year ago

Thanks @arthuredelstein - taking a look now! Fantastic repro steps!

kwhitley commented 1 year ago

So... this looks like a side effect of environments and how they handle (or don't) the direct serialization of the Headers class (which request.headers will typically be).

To test this, try accessing a known header directly:

router
  // this should work everywhere
  .get('/direct', () => request.headers.get('user-agent'))

  // so should this (but as an array)
  .get('/entries', () => [ ...request.headers.entries() ])

These should both work, regardless of environment I suspect.

As it stands, request.headers is a read-only property (of the Request API), limiting our ability to "reform" the attribute, even with a little middleware. To work around it, you can either:

  1. Expose the headers as a different attribute (e.g. headersAsObject)
  2. Use the proxy chain available within itty-router requests to trap the request to headers and redirect it elsewhere.

For example, these both work:

  // GET HEADERS AS OBJECT (different attribute)
  .get('/headers-middleware',
    (request) => {
      const headers = request.headersAsObject = {}
      for (let [key, value] of request.headers.entries()) {
        headers[key] = value
      }
    },
    ({ headersAsObject }) => headersAsObject
  )

  // GET HEADERS AS OBJECT (via Proxy)
  .get('/headers-middleware-direct',
    (request) => {
      const headers = request.headersAsObject = {}
      for (let [key, value] of request.headers.entries()) {
        headers[key] = value
      }
      request.proxy = new Proxy(request.proxy || request, {
        get: (obj, prop) => prop === 'headers' ? headers : obj[prop]
      })
    },
    ({ headers }) => headers
  )
kwhitley commented 1 year ago

I should caveat that adding middleware like the last example (that proxies over the request.headers) would break the standard request.headers.get('some-header') API which is commonly used, so use with care!

kwhitley commented 1 year ago

Anyway, as it stands, this is perhaps something to note in our docs, but not a bug to fix - rather just an anomaly of using the web standards API! :)

arthuredelstein commented 1 year ago

I see! Makes sense @kwhitley. Thank you very much for figuring it out and explaining it.

kwhitley commented 1 year ago

Happy to! I've noticed something similar in my own tinkering, yet never really gave it the actual thought/investigation to realize why exactly that was happening. I assumed it was something of a serialization issue, but never confirmed - so it was a fun little learning experience for both of us! :)