Yo Odin ยท Chapter 4

Chapter 4: Serving the game: a tiny web server in Odin

This chapter: build a static-file HTTP server in ~100 lines of Odin, compiled to a native binary, so we can load the WASM over HTTP with the right MIME types. Then wrap it in build/run scripts.

Where we left off: the Chapter 3 spike compiles but can't be opened from disk.


4.1 Why we can't just open the file

instantiateStreaming(fetch("โ€ฆโ€‹.wasm")) fetches the .wasm over HTTP. Under a file:// URL the browser blocks that fetch with a CORS error, so the page never loads. We need an HTTP server. And one detail is non-negotiable:

Gotcha. The server must send .wasm files with Content-Type: application/wasm. Streaming instantiation validates the MIME type and refuses anything else. A server that serves your wasm as application/octet-stream will fail with a confusing error even though the bytes are correct.

We could grab an off-the-shelf server, but writing our own is a dozen concepts in one small program, and it's our second Odin build target: native, not WASM.

4.2 Listening for connections

core:net gives us TCP. Bind a socket to a port and accept connections in a loop:

// server/serve.odin
package main

import "core:fmt"
import "core:net"
import "core:os"
import "core:strings"

ROOT :: "dist"   // directory served, relative to where you run the binary
PORT :: 9000

main :: proc() {
	endpoint := net.Endpoint{
		address = net.IP4_Address{0, 0, 0, 0},   // all interfaces
		port    = PORT,
	}

	listen, lerr := net.listen_tcp(endpoint)
	if lerr != nil {
		fmt.eprintfln("could not listen on port %d: %v", PORT, lerr)
		os.exit(1)
	}
	defer net.close(listen)

	fmt.printfln("Serving ./%s at http://localhost:%d  (Ctrl-C to stop)", ROOT, PORT)

	for {
		client, _, aerr := net.accept_tcp(listen)
		if aerr != nil {
			fmt.eprintfln("accept failed: %v", aerr)
			continue
		}
		handle_connection(client)
		net.close(client)
	}
}

This is deliberately a development server: single-threaded, one request per connection, no keep-alive. That's all a local dev loop needs, and it keeps the code readable. A production server would add concurrency, timeouts, caching headers, and range requests: out of scope here.

4.3 Reading the request and finding the file

An HTTP request starts with a line like GET /batch.js HTTP/1.1. We only need the path (the middle field). We read the request into a stack buffer, pull out the path, map / to index.html, refuse anything with .., and prepend the served root:

handle_connection :: proc(client: net.TCP_Socket) {
	buf: [4096]u8
	n, rerr := net.recv_tcp(client, buf[:])
	if rerr != nil || n == 0 {
		return
	}

	path := request_path(string(buf[:n]))
	if path == "" {
		send_status(client, 400, "Bad Request")
		return
	}

	if path == "/" {
		path = "/index.html"
	}
	if strings.contains(path, "..") {
		send_status(client, 403, "Forbidden")   // never let a path escape ROOT
		return
	}

	full := strings.concatenate({ROOT, path})
	defer delete(full)

	data, derr := os.read_entire_file_from_path(full, context.allocator)
	if derr != nil {
		send_status(client, 404, "Not Found")
		return
	}
	defer delete(data)

	send_file(client, content_type(path), data)
}

request_path :: proc(req: string) -> string {
	line := req
	if i := strings.index_any(req, "\r\n"); i >= 0 {
		line = req[:i]
	}
	parts := strings.fields(line)     // ["GET", "/batch.js", "HTTP/1.1"]
	defer delete(parts)
	if len(parts) < 2 {
		return ""
	}
	return parts[1]
}

Why the .. check? Without it, a request for /../serve.odin would hand out files outside the served directory. Refusing any path containing .. is a blunt but effective guard for a dev server.

4.4 MIME types

One tiny table, and the application/wasm line is the one that earns its keep:

content_type :: proc(path: string) -> string {
	switch {
	case strings.has_suffix(path, ".html"): return "text/html; charset=utf-8"
	case strings.has_suffix(path, ".js"):   return "text/javascript; charset=utf-8"
	case strings.has_suffix(path, ".css"):  return "text/css; charset=utf-8"
	case strings.has_suffix(path, ".wasm"): return "application/wasm"
	case strings.has_suffix(path, ".json"): return "application/json; charset=utf-8"
	case:                                   return "application/octet-stream"
	}
}

4.5 Writing the response

A minimal HTTP/1.1 response is a status line, a couple of headers, a blank line, then the body:

send_file :: proc(client: net.TCP_Socket, ctype: string, body: []u8) {
	header := fmt.tprintf(
		"HTTP/1.1 200 OK\r\nContent-Type: %s\r\nContent-Length: %d\r\nConnection: close\r\n\r\n",
		ctype, len(body),
	)
	net.send_tcp(client, transmute([]u8)header)
	net.send_tcp(client, body)
}

send_status :: proc(client: net.TCP_Socket, code: int, text: string) {
	body := fmt.tprintf("%d %s\n", code, text)
	resp := fmt.tprintf(
		"HTTP/1.1 %d %s\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s",
		code, text, len(body), body,
	)
	net.send_tcp(client, transmute([]u8)resp)
}

fmt.tprintf formats into temporary storage; transmute([]u8)string reinterprets the string's bytes as a byte slice to send. Connection: close tells the browser we hang up after one response, which matches our one-request-per-connection loop.

4.6 Building and running it

This is a native build: no -target, no -no-entry-point; it has a real main. Note the output name differs from the package directory (server/) so it doesn't collide:

odin build server -out:serve
./serve
# => Serving ./dist at http://localhost:9000  (Ctrl-C to stop)

4.7 The build and run scripts

Two small shell scripts tie it together. build.sh compiles the game and assembles dist/ (we'll fill in the game in later chapters; the shape is already right):

#!/usr/bin/env bash
# build.sh: compile the game to WASM and assemble dist/ (plain, unminified).
set -euo pipefail
cd "$(dirname "$0")"
mkdir -p dist

echo "==> Compiling Odin -> WebAssembly ..."
odin build src -out:dist/destroids.wasm -target:js_wasm32 -no-entry-point -o:size

echo "==> Copying assets ..."
cp web/batch.js dist/batch.js
cp index.html   dist/index.html

ls -lh dist/

run.sh builds everything and starts the server:

#!/usr/bin/env bash
# run.sh: build the game and server, then serve dist/ on :9000.
set -euo pipefail
cd "$(dirname "$0")"

bash build.sh

echo "==> Building the dev server (native) ..."
odin build server -out:serve -o:size

echo "=== Starting server ==="
./serve

These are a starting point: change the flags, names, or layout freely. On Windows, the same two odin build lines work; wrap them in a .bat/.ps1 and run serve.exe.

Make them executable and you have your dev loop:

chmod +x build.sh run.sh
./run.sh          # then open http://localhost:9000

Point the Chapter 3 spike's HTML at this server (drop it in dist/) and the blue rectangle finally appears, the round trip from Chapter 3, now actually on screen.

Checkpoint

You have a native Odin HTTP server that serves dist/ on port 9000 with correct MIME types (including application/wasm), refuses path-escape attempts, and returns 404s, plus build.sh and run.sh to drive the whole loop.

Exercises

  1. Add a startup line that logs each request's method and path as it's served.
  2. Add a .svg โ†’ image/svg+xml row to content_type.
  3. Request a missing file and confirm you get a 404; request / and confirm you get index.html.

Further reading