Memra

Serving many files: media types and binary output

◈ 5 cards

Guess the media type from the filename, read the file into a byte array, and flush the header through the Writer before a single image byte reaches the socket — the trap that corrupts every PNG and PDF A2 has to serve.

One socket, two views of it

A response is part text and part arbitrary bytes. The status line and headers are protocol text you build with string concatenation; the body may be a PNG, a PDF or a font, and it must arrive byte for byte. The clean way to serve both is to hold two references to one sink: a BufferedOutputStream wrapped straight around socket.getOutputStream(), and an OutputStreamWriter wrapped around that same buffered stream. The Writer is a convenience layer over the byte stream, not a second destination — everything you write to it lands in the same buffer, which is precisely why the order it lands in is your responsibility.

Naming the content

The client needs a Content-Type and all you have is a filename. Two JDK calls guess for you. Files.probeContentType(path) asks the platform (on Linux, the shared MIME database) and is usually the better answer. URLConnection.getFileNameMap().getContentTypeFor(name) consults a small table shipped with the JDK, works purely from the extension, and so needs no file on disk.

Both can return null, so try one, fall back to the other, and end with application/octet-stream — the media type that means "bytes, and I will not pretend to know more". A browser handed that offers a download instead of rendering garbage, which is the right failure. Guessing text/html for everything is how a JPEG ends up displayed as mojibake.

A Writer is a charset translator, and binary has no charset

Here is the trap that eats A2 submissions. A Writer accepts chars, not bytes. To push a file through one you must first decode its bytes into characters and then let the Writer encode them again. Those two steps are inverses only if both use the same charset, and the moment they do not, the bytes change.

Concretely: byte 0x89 (the first byte of every PNG) decoded as ISO-8859-1 becomes the character U+0089, and encoding U+0089 as UTF-8 emits two bytes, 0xC2 0x89. Every non-ASCII byte in the file grows, the image is corrupt, and the Content-Length you already sent is now a lie. The fix is not a better charset — it is not doing the conversion at all. Write the header through the Writer, flush it, then write the file’s bytes to the underlying OutputStream.

That flush() is about ordering, not just delivery. The Writer buffers characters; if you skip the flush, its header bytes reach the shared stream after the image bytes you wrote directly, and the client receives a file with a header glued to the end of it.

Content-Length is a byte count

Files.readAllBytes(path) hands you the exact array you are going to write, so data.length is the honest length and you should read it from there rather than from File.length() or a character count. Reading the whole file into memory is right for the assets a course assignment serves; for anything large, stream it with Files.copy(path, raw) and take the length from Files.size(path) instead.

Worked example — one handler, index.html and logo.png

private void serveFile(Path file, String version, Writer out, OutputStream raw)
        throws IOException {
    byte[] data = Files.readAllBytes(file);
    String type = mediaTypeOf(file);

    if (version.startsWith("HTTP/")) {
        out.write("HTTP/1.1 200 OK\r\n");
        out.write("Content-Type: " + type + "\r\n");
        out.write("Content-Length: " + data.length + "\r\n");
        out.write("Connection: close\r\n\r\n");
        out.flush();            // the header must reach the stream FIRST
    }
    raw.write(data);            // bytes, untranslated
    raw.flush();
}

private static String mediaTypeOf(Path file) throws IOException {
    String guess = Files.probeContentType(file);
    if (guess == null) {
        guess = URLConnection.getFileNameMap()
                .getContentTypeFor(file.getFileName().toString());
    }
    return guess == null ? "application/octet-stream" : guess;
}

One method serves both files, and the only difference is the string mediaTypeOf returns: text/html for index.html, image/png for logo.png. Nothing in the byte path branches on file type, which is the point — a handler that treats "text files" and "binary files" differently has invented a distinction HTTP does not have.

Verify it the way a marker will: fetch the PNG and compare it with the original — cmp logo.png downloaded.png must be silent. A visual check is not enough, because a corrupted PNG often still renders and a corrupted PDF often still opens.

client.getOutputStream()the socketBufferedOutputStream rawthe one sinkOutputStreamWriter outheader text onlyout.flush()header lands firstraw.write(data)bytes, untranslatedraw.flush()then closeSkip the flush and the headerarrives after the image.
One socket, one buffer, two references to it. The header goes through the Writer because it is text; the body goes to the raw stream because it is not. The flush in the middle is what keeps the two in the right order.
byte in the fileraw OutputStreamvia a String + UTF-8Writer0x3C "<"0x3C0x3C0x89 PNG signature0x890xC2 0x890xFF JPEG marker0xFF0xC3 0xBFOne byte becomes two, and Content-Length now lies.
ASCII survives the round trip, which is why the bug hides during testing on an HTML page and only appears on the first image. Every byte above 0x7F becomes two, so the file is corrupt and the Content-Length you already sent is short.
NORMAL ~/memra/learn/comp-348/http-server-binary-files utf-8 LF