diff --git a/src/main/php/web/handler/FilesFrom.class.php b/src/main/php/web/handler/FilesFrom.class.php
index 0d52e75f..1e04483a 100755
--- a/src/main/php/web/handler/FilesFrom.class.php
+++ b/src/main/php/web/handler/FilesFrom.class.php
@@ -1,20 +1,16 @@
 <?php namespace web\handler;
 
 use io\{File, Path};
-use util\MimeType;
-use web\io\Ranges;
-use web\{Handler, Headers};
+use web\Handler;
+use web\io\StaticContent;
 
 class FilesFrom implements Handler {
-  const BOUNDARY  = '594fa07300f865fe';
-  const CHUNKSIZE = 8192;
-
-  private $path;
-  private $headers= [];
+  private $path, $content;
 
   /** @param io.Path|io.Folder|string $path */
   public function __construct($path) {
     $this->path= $path instanceof Path ? $path : new Path($path);
+    $this->content= new StaticContent();
   }
 
   /** @return io.Path */
@@ -27,7 +23,7 @@ public function path() { return $this->path; }
    * @return self
    */
   public function with($headers) {
-    $this->headers= $headers;
+    $this->content->with($headers);
     return $this;
   }
 
@@ -61,31 +57,13 @@ public function handle($request, $response) {
       $file= $target->asFile();
     }
 
-    return $this->serve($request, $response, $file);
-  }
-
-  /**
-   * Copies a given amount of bytes from the specified file to the output
-   *
-   * @param  web.io.Output $out
-   * @param  io.File $file
-   * @param  web.io.Range $range
-   * @return iterable
-   */
-  private function copy($out, $file, $range) {
-    $file->seek($range->start());
-
-    $length= $range->length();
-    while ($length && $chunk= $file->read(min(self::CHUNKSIZE, $length))) {
-      yield 'write' => null;
-      $out->write($chunk);
-      $length-= strlen($chunk);
-    }
+    return $this->content->serve($request, $response, $file);
   }
 
   /**
-   * Serves a single file
+   * Serves a single file.
    *
+   * @deprecated Use `web.io.StaticContent` directly!
    * @param   web.Request $request
    * @param   web.Response $response
    * @param   ?io.File|io.Path|string $target
@@ -93,96 +71,6 @@ private function copy($out, $file, $range) {
    * @return  iterable
    */
   public function serve($request, $response, $target, $mimeType= null) {
-    if (null === $target || ($file= $target instanceof File ? $target : new File($target)) && !$file->exists()) {
-      $response->answer(404, 'Not Found');
-      $response->send('The file \''.$request->uri()->path().'\' was not found', 'text/plain');
-      return;
-    }
-
-    $lastModified= $file->lastModified();
-    if ($conditional= $request->header('If-Modified-Since')) {
-      if ($lastModified <= strtotime($conditional)) {
-        $response->answer(304, 'Not Modified');
-        $response->flush();
-        return;
-      }
-    }
-
-    $mimeType ?? $mimeType= MimeType::getByFileName($file->filename);
-    $response->header('Accept-Ranges', 'bytes');
-    $response->header('Last-Modified', Headers::date($lastModified));
-    $response->header('X-Content-Type-Options', 'nosniff');
-    $headers= is_callable($this->headers) ? ($this->headers)($request->uri(), $target, $mimeType) : $this->headers;
-    foreach ($headers as $name => $value) {
-      $response->header($name, $value);
-    }
-
-    if (null === ($ranges= Ranges::in($request->header('Range'), $file->size()))) {
-      $response->answer(200, 'OK');
-      $response->header('Content-Type', $mimeType);
-
-      if ('HEAD' === $request->method()) {
-        $response->header('Content-Length', $file->size());
-        $response->flush();
-      } else {
-        $out= $response->stream($file->size());
-        $file->open(File::READ);
-        try {
-          do {
-            yield 'write' => null;
-            $out->write($file->read(self::CHUNKSIZE));
-          } while (!$file->eof());
-        } finally {
-          $file->close();
-          $out->close();
-        }
-      }
-      return;
-    }
-
-    if (!$ranges->satisfiable() || 'bytes' !== $ranges->unit()) {
-      $response->answer(416, 'Range Not Satisfiable');
-      $response->header('Content-Range', 'bytes */'.$ranges->complete());
-      $response->flush();
-      return;
-    }
-
-    $file->open(File::READ);
-    $response->answer(206, 'Partial Content');
-
-    try {
-      if ($range= $ranges->single()) {
-        $response->header('Content-Type', $mimeType);
-        $response->header('Content-Range', $ranges->format($range));
-
-        $out= $response->stream($range->length());
-        yield from $this->copy($out, $file, $range);
-      } else {
-        $headers= [];
-        $trailer= "\r\n--".self::BOUNDARY."--\r\n";
-        $length= strlen($trailer);
-
-        foreach ($ranges->sets() as $i => $range) {
-          $headers[$i]= $header= sprintf(
-            "\r\n--%s\r\nContent-Type: %s\r\nContent-Range: %s\r\n\r\n",
-            self::BOUNDARY,
-            $mimeType,
-            $ranges->format($range)
-          );
-          $length+= strlen($header) + $range->length();
-        }
-        $response->header('Content-Type', 'multipart/byteranges; boundary='.self::BOUNDARY);
-
-        $out= $response->stream($length);
-        foreach ($ranges->sets() as $i => $range) {
-          $out->write($headers[$i]);
-          yield from $this->copy($out, $file, $range);
-        }
-        $out->write($trailer);
-      }
-    } finally {
-      $file->close();
-      $out->close();
-    }
+    return $this->content->serve($request, $response, $target, $mimeType);
   }
 }
\ No newline at end of file
diff --git a/src/main/php/web/io/StaticContent.class.php b/src/main/php/web/io/StaticContent.class.php
new file mode 100755
index 00000000..f5aa6d0f
--- /dev/null
+++ b/src/main/php/web/io/StaticContent.class.php
@@ -0,0 +1,160 @@
+<?php namespace web\io;
+
+use io\File;
+use util\MimeType;
+use web\Headers;
+
+/**
+ * Serves static content by streaming given files. Handles HEAD,
+ * conditional and byte range requests. Suppresses content type
+ * detection by adding `X-Content-Type-Options: nosniff`.
+ *
+ * ```php
+ * $content= (new StaticContent())->with(['Cache-Control' => '...']);
+ * return $content->serve($req, $res, new File('...'), 'image/gif');
+ * ```
+ *
+ * @test web.unittest.io.StaticContentTest
+ * @see  web.handler.FilesFrom
+ * @see  https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Content-Type-Options
+ */
+class StaticContent {
+  const BOUNDARY  = '594fa07300f865fe';
+  const CHUNKSIZE = 8192;
+
+  private $headers= [];
+
+  /**
+   * Adds headers to successful responses, either from an array or a function.
+   *
+   * @param  [:string]|function(util.URI, io.File, string): iterable $headers
+   * @return self
+   */
+  public function with($headers) {
+    $this->headers= $headers;
+    return $this;
+  }
+
+  /**
+   * Copies a given amount of bytes from the specified file to the output
+   *
+   * @param  web.io.Output $out
+   * @param  io.File $file
+   * @param  web.io.Range $range
+   * @return iterable
+   */
+  private function copy($out, $file, $range) {
+    $file->seek($range->start());
+
+    $length= $range->length();
+    while ($length && $chunk= $file->read(min(self::CHUNKSIZE, $length))) {
+      yield 'write' => $out;
+      $out->write($chunk);
+      $length-= strlen($chunk);
+    }
+  }
+
+  /**
+   * Serves a single file. If no mime type is given, it is detected from
+   * the given file's extension.
+   *
+   * @param   web.Request $request
+   * @param   web.Response $response
+   * @param   ?io.File|io.Path|string $target
+   * @param   ?string $mimeType
+   * @return  iterable
+   */
+  public function serve($request, $response, $target, $mimeType= null) {
+    if (null === $target || ($file= $target instanceof File ? $target : new File($target)) && !$file->exists()) {
+      $response->answer(404, 'Not Found');
+      $response->send('The file \''.$request->uri()->path().'\' was not found', 'text/plain');
+      return;
+    }
+
+    $lastModified= $file->lastModified();
+    if ($conditional= $request->header('If-Modified-Since')) {
+      if ($lastModified <= strtotime($conditional)) {
+        $response->answer(304, 'Not Modified');
+        $response->flush();
+        return;
+      }
+    }
+
+    $mimeType ?? $mimeType= MimeType::getByFileName($file->filename);
+    $response->header('Accept-Ranges', 'bytes');
+    $response->header('Last-Modified', Headers::date($lastModified));
+    $response->header('X-Content-Type-Options', 'nosniff');
+    $headers= is_callable($this->headers) ? ($this->headers)($request->uri(), $target, $mimeType) : $this->headers;
+    foreach ($headers as $name => $value) {
+      $response->header($name, $value);
+    }
+
+    if (null === ($ranges= Ranges::in($request->header('Range'), $file->size()))) {
+      $response->answer(200, 'OK');
+      $response->header('Content-Type', $mimeType);
+
+      if ('HEAD' === $request->method()) {
+        $response->header('Content-Length', $file->size());
+        $response->flush();
+      } else {
+        $out= $response->stream($file->size());
+        $file->open(File::READ);
+        try {
+          do {
+            yield 'write' => $out;
+            $out->write($file->read(self::CHUNKSIZE));
+          } while (!$file->eof());
+        } finally {
+          $file->close();
+          $out->close();
+        }
+      }
+      return;
+    }
+
+    if (!$ranges->satisfiable() || 'bytes' !== $ranges->unit()) {
+      $response->answer(416, 'Range Not Satisfiable');
+      $response->header('Content-Range', 'bytes */'.$ranges->complete());
+      $response->flush();
+      return;
+    }
+
+    $file->open(File::READ);
+    $response->answer(206, 'Partial Content');
+
+    try {
+      if ($range= $ranges->single()) {
+        $response->header('Content-Type', $mimeType);
+        $response->header('Content-Range', $ranges->format($range));
+
+        $out= $response->stream($range->length());
+        yield from $this->copy($out, $file, $range);
+      } else {
+        $headers= [];
+        $trailer= "\r\n--".self::BOUNDARY."--\r\n";
+        $length= strlen($trailer);
+
+        foreach ($ranges->sets() as $i => $range) {
+          $headers[$i]= $header= sprintf(
+            "\r\n--%s\r\nContent-Type: %s\r\nContent-Range: %s\r\n\r\n",
+            self::BOUNDARY,
+            $mimeType,
+            $ranges->format($range)
+          );
+          $length+= strlen($header) + $range->length();
+        }
+        $response->header('Content-Type', 'multipart/byteranges; boundary='.self::BOUNDARY);
+
+        $out= $response->stream($length);
+        foreach ($ranges->sets() as $i => $range) {
+          $out->write($headers[$i]);
+          yield from $this->copy($out, $file, $range);
+        }
+        $out->write($trailer);
+      }
+    } finally {
+      $file->close();
+      $out->close();
+    }
+  }
+}
\ No newline at end of file
diff --git a/src/test/php/web/unittest/handler/FilesFromTest.class.php b/src/test/php/web/unittest/handler/FilesFromTest.class.php
index dfb5380b..80f7061c 100755
--- a/src/test/php/web/unittest/handler/FilesFromTest.class.php
+++ b/src/test/php/web/unittest/handler/FilesFromTest.class.php
@@ -44,59 +44,27 @@ private function pathWith($files) {
   }
 
   /**
-   * Assertion helper
-   *
-   * @param  string $expected
-   * @param  web.Response $response
-   * @throws unittest.AssertionFailedError
-   */
-  private function assertResponse($expected, $response) {
-    Assert::equals($expected, preg_replace(
-      '/[a-z]{3}, [0-9]{2} [a-z]{3} [0-9]{4} [0-9:]{8} GMT/i',
-      '<Date>',
-      $response->output()->bytes()
-    ));
-  }
-
-  /**
-   * Invokes handle()
+   * Invokes `handle()`
    *
    * @param  web.handler.FilesFrom $files
    * @param  web.Request $req
-   * @return web.Response
+   * @return string
    */
   private function handle($files, $req) {
     $res= new Response(new TestOutput());
-
     try {
       foreach ($files->handle($req, $res) ?? [] as $_) { }
-      return $res;
     } finally {
       $res->end();
     }
-  }
 
-  /**
-   * Invokes serve()
-   *
-   * @param  web.handler.FilesFrom $files
-   * @param  io.File $file
-   * @param  ?string $mime
-   * @return web.Response
-   */
-  private function serve($files, $file, $mime= null) {
-    $res= new Response(new TestOutput());
-    $req= new Request(new TestInput('GET', '/'));
-
-    try {
-      foreach ($files->serve($req, $res, $file, $mime) ?? [] as $_) { }
-      return $res;
-    } finally {
-      $res->end();
-    }
+    return preg_replace(
+      '/[a-z]{3}, [0-9]{2} [a-z]{3} [0-9]{4} [0-9:]{8} GMT/i',
+      '<Date>',
+      $res->output()->bytes()
+    );
   }
 
-  /** @return void */
   #[After]
   public function tearDown() {
     foreach ($this->cleanup as $folder) {
@@ -117,7 +85,7 @@ public function path($arg) {
   #[Test]
   public function existing_file() {
     $files= new FilesFrom($this->pathWith(['test.html' => 'Test']));
-    $this->assertResponse(
+    Assert::equals(
       "HTTP/1.1 200 OK\r\n".
       "Accept-Ranges: bytes\r\n".
       "Last-Modified: <Date>\r\n".
@@ -133,7 +101,7 @@ public function existing_file() {
   #[Test]
   public function existing_file_with_headers() {
     $files= (new FilesFrom($this->pathWith(['test.html' => 'Test'])))->with(['Cache-Control' => 'no-cache']);
-    $this->assertResponse(
+    Assert::equals(
       "HTTP/1.1 200 OK\r\n".
       "Accept-Ranges: bytes\r\n".
       "Last-Modified: <Date>\r\n".
@@ -154,7 +122,7 @@ public function existing_file_with_headers_function() {
         yield 'Cache-Control' => 'no-cache';
       }
     });
-    $this->assertResponse(
+    Assert::equals(
       "HTTP/1.1 200 OK\r\n".
       "Accept-Ranges: bytes\r\n".
       "Last-Modified: <Date>\r\n".
@@ -168,22 +136,10 @@ public function existing_file_with_headers_function() {
     );
   }
 
-  #[Test]
-  public function existing_file_unmodified_since() {
-    $files= new FilesFrom($this->pathWith(['test.html' => 'Test']));
-    $this->assertResponse(
-      "HTTP/1.1 304 Not Modified\r\n".
-      "\r\n",
-      $this->handle($files, new Request(new TestInput('GET', '/test.html', [
-        'If-Modified-Since' => Headers::date(time() + 1)
-      ])))
-    );
-  }
-
   #[Test]
   public function index_html() {
     $files= new FilesFrom($this->pathWith(['index.html' => 'Home']));
-    $this->assertResponse(
+    Assert::equals(
       "HTTP/1.1 200 OK\r\n".
       "Accept-Ranges: bytes\r\n".
       "Last-Modified: <Date>\r\n".
@@ -199,7 +155,7 @@ public function index_html() {
   #[Test]
   public function redirect_if_trailing_slash_missing() {
     $files= new FilesFrom($this->pathWith(['preview' => ['index.html' => 'Home']]));
-    $this->assertResponse(
+    Assert::equals(
       "HTTP/1.1 301 Moved Permanently\r\n".
       "Location: preview/\r\n".
       "\r\n",
@@ -210,7 +166,7 @@ public function redirect_if_trailing_slash_missing() {
   #[Test]
   public function non_existant_file() {
     $files= new FilesFrom($this->pathWith([]));
-    $this->assertResponse(
+    Assert::equals(
       "HTTP/1.1 404 Not Found\r\n".
       "Content-Type: text/plain\r\n".
       "Content-Length: 35\r\n".
@@ -223,7 +179,7 @@ public function non_existant_file() {
   #[Test]
   public function non_existant_index_html() {
     $files= new FilesFrom($this->pathWith([]));
-    $this->assertResponse(
+    Assert::equals(
       "HTTP/1.1 404 Not Found\r\n".
       "Content-Type: text/plain\r\n".
       "Content-Length: 26\r\n".
@@ -233,113 +189,10 @@ public function non_existant_index_html() {
     );
   }
 
-  #[Test, Values(['/../credentials', '/static/../../credentials'])]
-  public function cannot_access_below_path_root($uri) {
-    $files= new FilesFrom(new Folder($this->pathWith(['credentials' => 'secret']), 'webroot'));
-    $this->assertResponse(
-      "HTTP/1.1 404 Not Found\r\n".
-      "Content-Type: text/plain\r\n".
-      "Content-Length: 37\r\n".
-      "\r\n".
-      "The file '/credentials' was not found",
-      $this->handle($files, new Request(new TestInput('GET', $uri)))
-    );
-  }
-
-  #[Test, Values([['0-3', 'Home'], ['4-7', 'page'], ['0-0', 'H'], ['4-4', 'p'], ['7-7', 'e']])]
-  public function range_with_start_and_end($range, $result) {
-    $files= new FilesFrom($this->pathWith(['index.html' => 'Homepage']));
-    $this->assertResponse(
-      "HTTP/1.1 206 Partial Content\r\n".
-      "Accept-Ranges: bytes\r\n".
-      "Last-Modified: <Date>\r\n".
-      "X-Content-Type-Options: nosniff\r\n".
-      "Content-Type: text/html\r\n".
-      "Content-Range: bytes ".$range."/8\r\n".
-      "Content-Length: ".strlen($result)."\r\n".
-      "\r\n".
-      $result,
-      $this->handle($files, new Request(new TestInput('GET', '/', ['Range' => 'bytes='.$range])))
-    );
-  }
-
-  #[Test]
-  public function range_from_offset_until_end() {
-    $files= new FilesFrom($this->pathWith(['index.html' => 'Homepage']));
-    $this->assertResponse(
-      "HTTP/1.1 206 Partial Content\r\n".
-      "Accept-Ranges: bytes\r\n".
-      "Last-Modified: <Date>\r\n".
-      "X-Content-Type-Options: nosniff\r\n".
-      "Content-Type: text/html\r\n".
-      "Content-Range: bytes 4-7/8\r\n".
-      "Content-Length: 4\r\n".
-      "\r\n".
-      "page",
-      $this->handle($files, new Request(new TestInput('GET', '/', ['Range' => 'bytes=4-'])))
-    );
-  }
-
-  #[Test, Values([0, 8192, 10000])]
-  public function range_last_four_bytes($offset) {
-    $files= new FilesFrom($this->pathWith(['index.html' => str_repeat('*', $offset).'Homepage']));
-    $this->assertResponse(
-      "HTTP/1.1 206 Partial Content\r\n".
-      "Accept-Ranges: bytes\r\n".
-      "Last-Modified: <Date>\r\n".
-      "X-Content-Type-Options: nosniff\r\n".
-      "Content-Type: text/html\r\n".
-      "Content-Range: bytes ".($offset + 4)."-".($offset + 7)."/".($offset + 8)."\r\n".
-      "Content-Length: 4\r\n".
-      "\r\n".
-      "page",
-      $this->handle($files, new Request(new TestInput('GET', '/', ['Range' => 'bytes=-4'])))
-    );
-  }
-
-  #[Test, Values(['bytes=0-2000', 'bytes=4-2000', 'bytes=2000-', 'bytes=2000-2001', 'bytes=2000-0', 'bytes=4-0', 'characters=0-'])]
-  public function range_unsatisfiable($range) {
-    $files= new FilesFrom($this->pathWith(['index.html' => 'Homepage']));
-    $this->assertResponse(
-      "HTTP/1.1 416 Range Not Satisfiable\r\n".
-      "Accept-Ranges: bytes\r\n".
-      "Last-Modified: <Date>\r\n".
-      "X-Content-Type-Options: nosniff\r\n".
-      "Content-Range: bytes */8\r\n".
-      "\r\n",
-      $this->handle($files, new Request(new TestInput('GET', '/', ['Range' => $range])))
-    );
-  }
-
-  #[Test]
-  public function multi_range() {
-    $files= new FilesFrom($this->pathWith(['index.html' => 'Homepage']));
-    $this->assertResponse(
-      "HTTP/1.1 206 Partial Content\r\n".
-      "Accept-Ranges: bytes\r\n".
-      "Last-Modified: <Date>\r\n".
-      "X-Content-Type-Options: nosniff\r\n".
-      "Content-Type: multipart/byteranges; boundary=594fa07300f865fe\r\n".
-      "Content-Length: 186\r\n".
-      "\r\n".
-      "\r\n--594fa07300f865fe\r\n".
-      "Content-Type: text/html\r\n".
-      "Content-Range: bytes 0-3/8\r\n\r\n".
-      "Home".
-      "\r\n--594fa07300f865fe\r\n".
-      "Content-Type: text/html\r\n".
-      "Content-Range: bytes 4-7/8\r\n\r\n".
-      "page".
-      "\r\n--594fa07300f865fe--\r\n",
-      $this->handle($files, new Request(new TestInput('GET', '/', ['Range' => 'bytes=0-3,4-7'])))
-    );
-  }
-
-  #[Test]
-  public function call_serve_directly() {
-    $files= new FilesFrom('.');
-    $file= new File($this->pathWith(['test.html' => 'Test']), 'test.html');
-    $this->assertResponse(
+  #[Test, Values(['/./test.html', '/static/../test.html'])]
+  public function resolves_paths($uri) {
+    $files= new FilesFrom($this->pathWith(['test.html' => 'Test']));
+    Assert::equals(
       "HTTP/1.1 200 OK\r\n".
       "Accept-Ranges: bytes\r\n".
       "Last-Modified: <Date>\r\n".
@@ -348,66 +201,51 @@ public function call_serve_directly() {
       "Content-Length: 4\r\n".
       "\r\n".
       "Test",
-      $this->serve($files, $file)
-    );
-  }
-
-  #[Test]
-  public function call_serve_with_non_existant_file() {
-    $files= new FilesFrom('.');
-    $file= new File($this->pathWith([]), 'test.html');
-    $this->assertResponse(
-      "HTTP/1.1 404 Not Found\r\n".
-      "Content-Type: text/plain\r\n".
-      "Content-Length: 26\r\n".
-      "\r\n".
-      "The file '/' was not found",
-      $this->serve($files, $file)
+      $this->handle($files, new Request(new TestInput('GET', $uri)))
     );
   }
 
-  #[Test]
-  public function call_serve_without_file() {
-    $files= new FilesFrom('.');
-    $this->assertResponse(
+  #[Test, Values(['/../credentials', '/static/../../credentials'])]
+  public function cannot_access_below_path_root($uri) {
+    $files= new FilesFrom(new Folder($this->pathWith(['credentials' => 'secret']), 'webroot'));
+    Assert::equals(
       "HTTP/1.1 404 Not Found\r\n".
       "Content-Type: text/plain\r\n".
-      "Content-Length: 26\r\n".
+      "Content-Length: 37\r\n".
       "\r\n".
-      "The file '/' was not found",
-      $this->serve($files, null)
+      "The file '/credentials' was not found",
+      $this->handle($files, new Request(new TestInput('GET', $uri)))
     );
   }
 
+  /** @deprecated */
   #[Test]
-  public function overrride_mime_type_when_invoking_serve() {
+  public function call_serve_directly() {
     $files= new FilesFrom('.');
     $file= new File($this->pathWith(['test.html' => 'Test']), 'test.html');
-    $this->assertResponse(
-      "HTTP/1.1 200 OK\r\n".
-      "Accept-Ranges: bytes\r\n".
-      "Last-Modified: <Date>\r\n".
-      "X-Content-Type-Options: nosniff\r\n".
-      "Content-Type: text/html; charset=utf-8\r\n".
-      "Content-Length: 4\r\n".
-      "\r\n".
-      "Test",
-      $this->serve($files, $file, 'text/html; charset=utf-8')
-    );
-  }
 
-  #[Test]
-  public function head_method_on_existing_file() {
-    $files= new FilesFrom($this->pathWith(['test.html' => 'Test']));
-    $this->assertResponse(
+    $res= new Response(new TestOutput());
+    $req= new Request(new TestInput('GET', '/test.html'));
+    try {
+      foreach ($files->serve($req, $res, $file) ?? [] as $_) { }
+    } finally {
+      $res->end();
+    }
+
+    Assert::equals(
       "HTTP/1.1 200 OK\r\n".
       "Accept-Ranges: bytes\r\n".
       "Last-Modified: <Date>\r\n".
       "X-Content-Type-Options: nosniff\r\n".
       "Content-Type: text/html\r\n".
       "Content-Length: 4\r\n".
-      "\r\n",
-      $this->handle($files, new Request(new TestInput('HEAD', '/test.html')))
+      "\r\n".
+      "Test",
+      preg_replace(
+        '/[a-z]{3}, [0-9]{2} [a-z]{3} [0-9]{4} [0-9:]{8} GMT/i',
+        '<Date>',
+        $res->output()->bytes()
+      )
     );
   }
 }
\ No newline at end of file
diff --git a/src/test/php/web/unittest/io/StaticContentTest.class.php b/src/test/php/web/unittest/io/StaticContentTest.class.php
new file mode 100755
index 00000000..89430759
--- /dev/null
+++ b/src/test/php/web/unittest/io/StaticContentTest.class.php
@@ -0,0 +1,277 @@
+<?php namespace web\unittest\io;
+
+use io\{File, TempFile};
+use test\{Assert, Before, Test, Values};
+use web\io\{StaticContent, TestInput, TestOutput};
+use web\{Request, Response, Headers};
+
+class StaticContentTest {
+  private $file;
+
+  /**
+   * Invokes `serve()` and returns the HTTP response as a string.
+   *
+   * @param  web.io.StaticContent $content
+   * @param  ?io.File $file
+   * @param  ?string $mimeType
+   * @param  ?web.io.TestInput $input
+   * @return string
+   */
+  private function serve($content, $file, $mimeType= null, $input= null) {
+    $res= new Response(new TestOutput());
+    $req= new Request($input ?? new TestInput('GET', '/'));
+
+    try {
+      foreach ($content->serve($req, $res, $file, $mimeType) ?? [] as $_) { }
+    } finally {
+      $res->end();
+    }
+
+    return preg_replace(
+      '/[a-z]{3}, [0-9]{2} [a-z]{3} [0-9]{4} [0-9:]{8} GMT/i',
+      '<Date>',
+      $res->output()->bytes()
+    );
+  }
+
+  #[Before]
+  public function file() {
+    $this->file= (new TempFile(self::class))->containing('Homepage');
+    $this->file->move($this->file->getURI().'.html');
+  }
+
+  #[Test]
+  public function can_create() {
+    new StaticContent();
+  }
+
+  #[Test]
+  public function serve_file() {
+    Assert::equals(
+      "HTTP/1.1 200 OK\r\n".
+      "Accept-Ranges: bytes\r\n".
+      "Last-Modified: <Date>\r\n".
+      "X-Content-Type-Options: nosniff\r\n".
+      "Content-Type: text/html\r\n".
+      "Content-Length: 8\r\n".
+      "\r\n".
+      "Homepage",
+      $this->serve(new StaticContent(), $this->file, 'text/html')
+    );
+  }
+
+  #[Test]
+  public function mime_type_inferred_from_file_extension() {
+    Assert::equals(
+      "HTTP/1.1 200 OK\r\n".
+      "Accept-Ranges: bytes\r\n".
+      "Last-Modified: <Date>\r\n".
+      "X-Content-Type-Options: nosniff\r\n".
+      "Content-Type: text/html\r\n".
+      "Content-Length: 8\r\n".
+      "\r\n".
+      "Homepage",
+      $this->serve(new StaticContent(), $this->file)
+    );
+  }
+
+  #[Test]
+  public function head_requests_do_not_include_body() {
+    Assert::equals(
+      "HTTP/1.1 200 OK\r\n".
+      "Accept-Ranges: bytes\r\n".
+      "Last-Modified: <Date>\r\n".
+      "X-Content-Type-Options: nosniff\r\n".
+      "Content-Type: text/html\r\n".
+      "Content-Length: 8\r\n".
+      "\r\n",
+      $this->serve(new StaticContent(), $this->file, null, new TestInput('HEAD', '/'))
+    );
+  }
+
+  #[Test]
+  public function conditional_request() {
+    Assert::equals(
+      "HTTP/1.1 200 OK\r\n".
+      "Accept-Ranges: bytes\r\n".
+      "Last-Modified: <Date>\r\n".
+      "X-Content-Type-Options: nosniff\r\n".
+      "Content-Type: text/html\r\n".
+      "Content-Length: 8\r\n".
+      "\r\n".
+      "Homepage",
+      $this->serve(new StaticContent(), $this->file, null, new TestInput('GET', '/', [
+        'If-Modified-Since' => Headers::date(time() - 86400)
+      ]))
+    );
+  }
+
+  #[Test]
+  public function unmodified_since_date_in_conditional_request() {
+    Assert::equals(
+      "HTTP/1.1 304 Not Modified\r\n".
+      "\r\n",
+      $this->serve(new StaticContent(), $this->file, null, new TestInput('GET', '/', [
+        'If-Modified-Since' => Headers::date(time() + 86400)
+      ]))
+    );
+  }
+
+  #[Test]
+  public function with_headers() {
+    $headers= ['Cache-Control' => 'no-cache'];
+
+    Assert::equals(
+      "HTTP/1.1 200 OK\r\n".
+      "Accept-Ranges: bytes\r\n".
+      "Last-Modified: <Date>\r\n".
+      "X-Content-Type-Options: nosniff\r\n".
+      "Cache-Control: no-cache\r\n".
+      "Content-Type: text/html\r\n".
+      "Content-Length: 8\r\n".
+      "\r\n".
+      "Homepage",
+      $this->serve((new StaticContent())->with($headers), $this->file)
+    );
+  }
+
+  #[Test]
+  public function with_header_function() {
+    $headers= function($uri, $file, $mime) {
+      yield 'X-Access-Time' => Headers::date($file->lastAccessed());
+    };
+
+    Assert::equals(
+      "HTTP/1.1 200 OK\r\n".
+      "Accept-Ranges: bytes\r\n".
+      "Last-Modified: <Date>\r\n".
+      "X-Content-Type-Options: nosniff\r\n".
+      "X-Access-Time: <Date>\r\n".
+      "Content-Type: text/html\r\n".
+      "Content-Length: 8\r\n".
+      "\r\n".
+      "Homepage",
+      $this->serve((new StaticContent())->with($headers), $this->file)
+    );
+  }
+
+  #[Test]
+  public function serve_non_existant_yields_404() {
+    Assert::equals(
+      "HTTP/1.1 404 Not Found\r\n".
+      "Content-Type: text/plain\r\n".
+      "Content-Length: 26\r\n".
+      "\r\n".
+      "The file '/' was not found",
+      $this->serve(new StaticContent(), new File('does.not.exist'))
+    );
+  }
+
+  #[Test]
+  public function serve_null_yields_404() {
+    Assert::equals(
+      "HTTP/1.1 404 Not Found\r\n".
+      "Content-Type: text/plain\r\n".
+      "Content-Length: 35\r\n".
+      "\r\n".
+      "The file '/not-found' was not found",
+      $this->serve(new StaticContent(), null, null, new TestInput('GET', '/not-found'))
+    );
+  }
+
+  #[Test, Values([['0-3', 'Home'], ['4-7', 'page'], ['0-0', 'H'], ['4-4', 'p'], ['7-7', 'e']])]
+  public function range_with_start_and_end($range, $result) {
+    Assert::equals(
+      "HTTP/1.1 206 Partial Content\r\n".
+      "Accept-Ranges: bytes\r\n".
+      "Last-Modified: <Date>\r\n".
+      "X-Content-Type-Options: nosniff\r\n".
+      "Content-Type: text/html\r\n".
+      "Content-Range: bytes {$range}/8\r\n".
+      "Content-Length: ".strlen($result)."\r\n".
+      "\r\n".
+      $result,
+      $this->serve(new StaticContent(), $this->file, null, new TestInput('GET', '/', [
+        'Range' => "bytes={$range}" 
+      ]))
+    );
+  }
+
+  #[Test]
+  public function range_from_offset_until_end() {
+    Assert::equals(
+      "HTTP/1.1 206 Partial Content\r\n".
+      "Accept-Ranges: bytes\r\n".
+      "Last-Modified: <Date>\r\n".
+      "X-Content-Type-Options: nosniff\r\n".
+      "Content-Type: text/html\r\n".
+      "Content-Range: bytes 4-7/8\r\n".
+      "Content-Length: 4\r\n".
+      "\r\n".
+      "page",
+      $this->serve(new StaticContent(), $this->file, null, new TestInput('GET', '/', [
+        'Range' => 'bytes=4-' 
+      ]))
+    );
+  }
+
+  #[Test, Values([0, 8192, 10000])]
+  public function range_last_four_bytes($offset) {
+    $padded= (new TempFile(self::class))->containing(str_repeat('*', $offset).'Homepage');
+
+    Assert::equals(
+      "HTTP/1.1 206 Partial Content\r\n".
+      "Accept-Ranges: bytes\r\n".
+      "Last-Modified: <Date>\r\n".
+      "X-Content-Type-Options: nosniff\r\n".
+      "Content-Type: text/html\r\n".
+      "Content-Range: bytes ".($offset + 4)."-".($offset + 7)."/".($offset + 8)."\r\n".
+      "Content-Length: 4\r\n".
+      "\r\n".
+      "page",
+      $this->serve(new StaticContent(), $padded, 'text/html', new TestInput('GET', '/', [
+        'Range' => 'bytes=-4' 
+      ]))
+    );
+  }
+
+  #[Test]
+  public function multiple_ranges() {
+    Assert::equals(
+      "HTTP/1.1 206 Partial Content\r\n".
+      "Accept-Ranges: bytes\r\n".
+      "Last-Modified: <Date>\r\n".
+      "X-Content-Type-Options: nosniff\r\n".
+      "Content-Type: multipart/byteranges; boundary=594fa07300f865fe\r\n".
+      "Content-Length: 186\r\n".
+      "\r\n".
+      "\r\n--594fa07300f865fe\r\n".
+      "Content-Type: text/html\r\n".
+      "Content-Range: bytes 0-3/8\r\n\r\n".
+      "Home".
+      "\r\n--594fa07300f865fe\r\n".
+      "Content-Type: text/html\r\n".
+      "Content-Range: bytes 4-7/8\r\n\r\n".
+      "page".
+      "\r\n--594fa07300f865fe--\r\n",
+      $this->serve(new StaticContent(), $this->file, null, new TestInput('GET', '/', [
+        'Range' => 'bytes=0-3,4-7' 
+      ]))
+    );
+  }
+
+  #[Test, Values(['bytes=0-2000', 'bytes=4-2000', 'bytes=2000-', 'bytes=2000-2001', 'bytes=2000-0', 'bytes=4-0', 'characters=0-'])]
+  public function range_unsatisfiable($range) {
+    Assert::equals(
+      "HTTP/1.1 416 Range Not Satisfiable\r\n".
+      "Accept-Ranges: bytes\r\n".
+      "Last-Modified: <Date>\r\n".
+      "X-Content-Type-Options: nosniff\r\n".
+      "Content-Range: bytes */8\r\n".
+      "\r\n",
+      $this->serve(new StaticContent(), $this->file, null, new TestInput('GET', '/', [
+        'Range' => $range
+      ]))
+    );
+  }
+}
\ No newline at end of file