Memra

The smallest server that speaks HTTP

◈ 5 cards

One socket, one canned response: status line, headers, the blank line that ends them, then the body — plus why a request with no version token gets the body and no header at all.

A response is four things, in this order

You have read HTTP responses as a client. Writing one is the same document seen from the other side, and it stops being a page: it becomes a byte sequence you are responsible for emitting in the right order. A status line, then zero or more headers, then a blank line, then the body. Each of the first three parts is terminated by CRLF — the two bytes \r\n, not the single \n your platform calls a newline — and the blank line is simply a CRLF with nothing in front of it.

That blank line is the whole protocol in miniature. It is the only signal that the header block has ended and that the next byte is content. Send the body before it and the browser renders your headers as text. Leave it out and the client waits for a header block that never closes, until one side times out. Neither mistake raises an exception on your side, because both produce a stream that is still syntactically plausible.

Assemble the whole response once, then serve it as one array

A fixed-response server answers every request with the same content, so nothing in the reply depends on the request. Push that all the way: at construction, join the status line and the headers, encode them, and concatenate them with the file into a single byte[]. A connection then costs one write and one flush — no string building, no charset conversion, and no separately-held header whose length claim could ever drift from the bytes it introduces. That is the real argument for a purpose-built server: it does less work per connection, not the same work faster. It also removes the reason to wrap the socket in a BufferedOutputStream at all, since a buffer exists to coalesce small writes and there is now exactly one.

Keep a second array holding the file on its own, because the pre-1.0 clients in the next section get the body with no header in front of it. Two arrays, both built at startup; the handler only has to choose.

Two details fall out of it. Content-Length must be the length of the body byte array, not of the String you built it from — for any non-ASCII content those two numbers differ, and the client trusts the header. And the header is protocol text, so encode it as US-ASCII; the charset parameter you announce inside it describes the body, never the header.

Not every client speaks HTTP/1.1

The original protocol, HTTP/0.9, sent GET / and nothing else, and expected a document back with no status line and no headers at all. No browser does this any more, but hand-typed telnet sessions and three-line scripts still can, and your own testing will. The rule is small: if the request line names a version, answer with a header; if it does not, answer with the body alone. You read the first line, test it for the substring HTTP/, and branch on that.

That test is also why you read only the first line. A real request carries a dozen headers this server has no use for, and reading on can block against a client that has stopped sending. Handle one more case while you are there: a caller that connects and says nothing gives you a null line, not an exception. Treat null as "declared no version" and answer with the body alone.

Worked example — a fixed-response server

public final class FixedResponseServer {

    private static final Logger LOG = Logger.getLogger(FixedResponseServer.class.getName());
    private static final int WORKERS = 16;

    private final byte[] whole;   // status line + headers + blank line + page
    private final byte[] page;    // the file alone, for a caller that named no version
    private final int port;

    FixedResponseServer(Path file, String mediaType, Charset charset, int port)
            throws IOException {
        this.page = Files.readAllBytes(file);
        this.port = port;
        this.whole = prefixed(headerFor(mediaType, charset, page.length), page);
    }

    private static String headerFor(String mediaType, Charset charset, int length) {
        return String.join("\r\n",
                "HTTP/1.1 200 OK",
                "Content-Type: " + mediaType + "; charset=" + charset.name(),
                "Content-Length: " + length,
                "Connection: close",
                "", "");          // the two empty elements close the header block
    }

    private static byte[] prefixed(String header, byte[] body) {
        byte[] head = header.getBytes(StandardCharsets.US_ASCII);
        byte[] joined = Arrays.copyOf(head, head.length + body.length);
        System.arraycopy(body, 0, joined, head.length, body.length);
        return joined;
    }

    void serve() throws IOException {
        ExecutorService workers = Executors.newFixedThreadPool(WORKERS);
        try (ServerSocket listener = new ServerSocket(port)) {
            LOG.info("serving " + page.length + " bytes on port " + listener.getLocalPort());
            for (;;) {
                Socket caller = listener.accept();
                workers.execute(() -> answer(caller));
            }
        } finally {
            workers.shutdown();
        }
    }

    private void answer(Socket caller) {
        try (Socket open = caller) {
            BufferedReader asked = new BufferedReader(new InputStreamReader(
                    open.getInputStream(), StandardCharsets.US_ASCII));
            String requestLine = asked.readLine();     // null when the caller said nothing
            boolean readsHeaders = requestLine != null && requestLine.contains("HTTP/");

            OutputStream sink = open.getOutputStream();
            sink.write(readsHeaders ? whole : page);
            sink.flush();
        } catch (IOException gone) {
            LOG.log(Level.WARNING, "caller went away mid-response", gone);
        }
    }
}

Read it in three passes. The constructor does all the formatting the protocol needs, exactly once, and headerFor receives page.length — the length of the very array prefixed is about to append — so the announced length and the bytes sent cannot disagree. String.join carries the whole CRLF discipline in one call: it puts a separator between elements, so four header lines plus two empty elements produce a CRLF after each header and one more after the last, and that final one is the blank line.

The accept loop is the previous module’s, unchanged — accept() blocks, hands back a connected Socket, and the work goes to a bounded pool so one slow reader cannot stall the next visitor. shutdown() sits in a finally, so a port that will not bind does not leave sixteen non-daemon threads holding a dead JVM open. The handler reads one line, chooses an array, writes it, flushes.

Everything that can throw lives inside the handler, so a caller that disappears mid-write costs one warning and the accept loop carries on. try (Socket open = caller) closes the socket on every path, and closing a socket closes both of its streams — you never close sink yourself. Test it twice: telnet localhost 8080, type GET / HTTP/1.1 and a blank line to read the raw bytes, then open the same URL in a browser to confirm a real client accepts the header.

accept()one connected Socketstatus lineHTTP/1.1 200 OKheadersContent-Type, Content-Lengthblank lineCRLF, nothing before itbody bytesexactly Content-Lengthcloseboth streams close tooOne CRLF ends each header line;one more ends the block.
Read top to bottom — this is the order the bytes must leave the socket in. Only the blank line is invisible on screen, and it is the only one whose omission hangs the client instead of merely looking wrong.
NORMAL ~/memra/learn/comp-348/http-server-single-file utf-8 LF