Repository: ProjectEvergreen/greenwood (js)

Issue:
browsers inconsistently serving incorrect / stale content from dev server
## Type of Change
Bug

## Summary
Related to the recent [v0.28.0 release](https://github.com/ProjectEvergreen/greenwood/releases/tag/v0.28.0), it seems that browsers, in particular when switching projects that might run on the same port, load incorrect / stale data.  The only sure fire way to avoid this issue is to make sure cache is disabled in your browsers network tab while having it open.

https://github.com/ProjectEvergreen/greenwood/assets/895923/6a2b57a6-1fc0-4163-8682-3ee5d2dd65d4

I know others like @aholtzman have seen similar issues where content changes / reloads do not seem to take effect unless the browser console / dev tools are open.

## Details
In theory Greenwood should be helping to mitigate this using [ETag caching headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag) but there's a good chance the implementation was not sufficiently updated enough after the Web API standardization overhaul that occurred as part of that release.

My [suspicion lies here](https://github.com/ProjectEvergreen/greenwood/blob/v0.28.2/packages/cli/src/lifecycles/serve.js#L145), where we still treat the response body as a string, but post refactor, it is now a `ReadableStream`
```js
const etagHash = url.pathname.split('.').pop() === 'json'
  ? hashString(JSON.stringify(body))
  : hashString(body);
```

So I think it is just coming out as `'[object Object]'` each time?

Pull request description:
bug/issue 1108 update ETag caching to work with ReadableStreams
<!--
## Submitting a Pull Request
We love contributions and appreciate any help you can offer!
-->

## Related Issue
resolves #1108 

https://github.com/ProjectEvergreen/greenwood/assets/895923/24c2361e-4c53-4d24-a195-416036699f5d

## Summary of Changes
1. Update ETag implementation to work with `ReadableStream`
1. Removed "filter" around binary files, will now work with any `file:` types
1. Add test cases to cover etag hits and misses

## TODO
1. [x] Fix spec failing for missing content length
    <details>
      <pre>
        1) Develop Greenwood With:
         Default Greenwood Configuration and Workspace
           Develop command with generic video container format (.mp4) behavior
             should return the correct content length:
       AssertionError: expected undefined to equal '2498461'
        at Context.<anonymous> (file:///Users/owenbuckley/Workspace/project-evergreen/repos/greenwood/packages/cli/test/cases/develop.default/develop.default.spec.js:911:55)
        at process.processImmediate (node:internal/timers:471:21)
  
        2) Develop Greenwood With:
             Default Greenwood Configuration and Workspace
               Develop command with audio format (.mp3) behavior
                 should return the correct content length:
           AssertionError: expected undefined to equal '5425061'
            at Context.<anonymous> (file:///Users/owenbuckley/Workspace/project-evergreen/repos/greenwood/packages/cli/test/cases/develop.default/develop.default.spec.js:950:55)
            at process.processImmediate (node:internal/timers:471:21)
      </pre>
    </details>
1. [x] Add test case for etag header 
1. [x] Finalize testing / clean up consoles and comments
1. [x] Probably a good idea to make sure this and the disable cache work around are documented somewhere - already covered! - http://localhost:1984/about/how-it-works/#cli

Patch:
diff --git a/packages/cli/src/lifecycles/serve.js b/packages/cli/src/lifecycles/serve.js
index f23338e1..e0cc4f7a 100644
--- a/packages/cli/src/lifecycles/serve.js
+++ b/packages/cli/src/lifecycles/serve.js
@@ -132,27 +132,38 @@ async function getDevServer(compilation) {
   // ETag Support - https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag
   // https://stackoverflow.com/questions/43659756/chrome-ignores-the-etag-header-and-just-uses-the-in-memory-cache-disk-cache
   app.use(async (ctx) => {
-    const body = ctx.response.body;
     const url = new URL(ctx.url);
 
-    // don't interfere with external requests or API calls, binary files, or JSON
+    // don't interfere with external requests or API calls, only files
     // and only run in development
     if (process.env.__GWD_COMMAND__ === 'develop' && url.protocol === 'file:') { // eslint-disable-line no-underscore-dangle
-      if (!body || Buffer.isBuffer(body)) {
-        // console.warn(`no body for => ${ctx.url}`);
-      } else {
-        const inm = ctx.headers['if-none-match'];
-        const etagHash = url.pathname.split('.').pop() === 'json'
-          ? hashString(JSON.stringify(body))
-          : hashString(body);
-
-        if (inm && inm === etagHash) {
-          ctx.status = 304;
-          ctx.body = null;
-          ctx.set('Etag', etagHash);
-          ctx.set('Cache-Control', 'no-cache');
-        } else if (!inm || inm !== etagHash) {
-          ctx.set('Etag', etagHash);
+      // TODO there's probably a better way to do this with tee-ing streams but this works for now
+      const response = new Response(ctx.body, {
+        status: ctx.response.status,
+        headers: new Headers(ctx.response.header)
+      }).clone();
+      const splitResponse = response.clone();
+      const contents = await splitResponse.text();
+      const inm = ctx.headers['if-none-match'];
+      const etagHash = url.pathname.split('.').pop() === 'json'
+        ? hashString(JSON.stringify(contents))
+        : hashString(contents);
+
+      if (inm && inm === etagHash) {
+        ctx.status = 304;
+        ctx.body = null;
+        ctx.set('Etag', etagHash);
+        ctx.set('Cache-Control', 'no-cache');
+      } else if (!inm || inm !== etagHash) {
+        ctx.body = Readable.from(response.body);
+        ctx.status = ctx.status;
+        ctx.set('Content-Type', ctx.response.header['content-type']);
+        ctx.set('Etag', etagHash);
+  
+        // TODO automatically loop and apply all custom headers to Koa response, include Content-Type below
+        // https://github.com/ProjectEvergreen/greenwood/issues/1048
+        if (response.headers.has('Content-Length')) {
+          ctx.set('Content-Length', response.headers.get('Content-Length'));
         }
       }
     }


Test patch:
diff --git a/packages/cli/test/cases/develop.default/develop.default.spec.js b/packages/cli/test/cases/develop.default/develop.default.spec.js
index c20b6331..881adbed 100644
--- a/packages/cli/test/cases/develop.default/develop.default.spec.js
+++ b/packages/cli/test/cases/develop.default/develop.default.spec.js
@@ -902,22 +902,66 @@ describe('Develop Greenwood With: ', function() {
         done();
       });
 
-      it('should return the correct content type', function(done) {
+      it('should return the correct content type header', function(done) {
         expect(response.headers['content-type']).to.contain(ext);
         done();
       });
 
-      it('should return the correct content length', function(done) {
+      it('should return the correct content length header', function(done) {
         expect(response.headers['content-length']).to.equal('2498461');
         done();
       });
 
+      it('should return the correct etag header', function(done) {
+        expect(response.headers.etag).to.equal('2130309740');
+        done();
+      });
+
       it('should return the correct response body', function(done) {
         expect(response.body).to.contain(ext);
         done();
       });
     });
 
+    describe('Develop command with generic video container format (.mp4) behavior that should return an etag hit', function() {
+      let response = {};
+
+      before(async function() {
+        return new Promise((resolve, reject) => {
+          request.get({
+            url: `${hostname}:${port}/assets/splash-clip.mp4`,
+            headers: {
+              'if-none-match': '2130309740'
+            }
+          }, (err, res, body) => {
+            if (err) {
+              reject();
+            }
+
+            response = res;
+            response.body = body;
+
+            resolve();
+          });
+        });
+      });
+
+      it('should return a 304 status', function(done) {
+        expect(response.statusCode).to.equal(304);
+        done();
+      });
+
+      it('should return an empty body', function(done) {
+        expect(response.body).to.contain('');
+        done();
+      });
+
+      it('should return the correct cache-control header', function(done) {
+        expect(response.headers['cache-control']).to.equal('no-cache');
+        done();
+      });
+    });
+
     describe('Develop command with audio format (.mp3) behavior', function() {
       let response = {};