server.mjs

/**
 * Minimal production host for the TanStack Start build.
 *
 * `vite build` emits `dist/client` (static assets) and `dist/server/server.js`
 * (a default export that takes a `Request` and resolves to a `Response`).
 * This file serves the static assets directly and forwards everything else to
 * that handler, which keeps a single origin for the app, its API routes and
 * its server functions.
 */
import { createReadStream } from "node:fs";
import { stat } from "node:fs/promises";
import { createServer } from "node:http";
import { extname, join, resolve, sep } from "node:path";
import { Readable } from "node:stream";
import handler from "./dist/server/server.js";

const PORT = Number(process.env.PORT ?? 3000);
// Loopback by default: there is no access gate, so expose the app to other
// machines only by setting HOST explicitly.
const HOST = process.env.HOST ?? "127.0.0.1";
const CLIENT_ROOT = resolve(import.meta.dirname, "dist/client");

const MIME_TYPES = {
    ".css": "text/css; charset=utf-8",
    ".gif": "image/gif",
    ".html": "text/html; charset=utf-8",
    ".ico": "image/x-icon",
    ".jpeg": "image/jpeg",
    ".jpg": "image/jpeg",
    ".js": "text/javascript; charset=utf-8",
    ".json": "application/json; charset=utf-8",
    ".map": "application/json; charset=utf-8",
    ".mjs": "text/javascript; charset=utf-8",
    ".otf": "font/otf",
    ".png": "image/png",
    ".svg": "image/svg+xml",
    ".ttf": "font/ttf",
    ".txt": "text/plain; charset=utf-8",
    ".webmanifest": "application/manifest+json",
    ".webp": "image/webp",
    ".woff": "font/woff",
    ".woff2": "font/woff2",
    ".xml": "application/xml; charset=utf-8",
};

/** Resolve a request path to a file inside `dist/client`, or `undefined`. */
async function findStaticFile(pathname) {
    let decoded;
    try {
        decoded = decodeURIComponent(pathname);
    } catch {
        return undefined;
    }

    const candidate = resolve(join(CLIENT_ROOT, decoded));
    if (candidate !== CLIENT_ROOT && !candidate.startsWith(CLIENT_ROOT + sep)) {
        return undefined;
    }

    try {
        const info = await stat(candidate);
        return info.isFile() ? candidate : undefined;
    } catch {
        return undefined;
    }
}

function toWebRequest(request) {
    const url = `http://${request.headers.host ?? `localhost:${PORT}`}${request.url ?? "/"}`;
    const headers = new Headers();

    for (const [name, value] of Object.entries(request.headers)) {
        if (value === undefined) continue;
        if (Array.isArray(value)) {
            for (const entry of value) headers.append(name, entry);
        } else {
            headers.set(name, value);
        }
    }

    const method = request.method ?? "GET";
    const hasBody = method !== "GET" && method !== "HEAD";

    return new Request(url, {
        method,
        headers,
        body: hasBody ? Readable.toWeb(request) : undefined,
        // Required by undici when streaming a request body.
        ...(hasBody ? { duplex: "half" } : {}),
    });
}

async function sendWebResponse(response, nodeResponse) {
    nodeResponse.statusCode = response.status;
    nodeResponse.statusMessage = response.statusText;

    for (const [name, value] of response.headers) {
        if (name === "set-cookie") continue;
        nodeResponse.setHeader(name, value);
    }

    const cookies = response.headers.getSetCookie?.() ?? [];
    if (cookies.length > 0) nodeResponse.setHeader("set-cookie", cookies);

    if (!response.body) {
        nodeResponse.end();
        return;
    }

    Readable.fromWeb(response.body).pipe(nodeResponse);
}

const server = createServer(async (request, response) => {
    try {
        if (request.method === "GET" || request.method === "HEAD") {
            const pathname = (request.url ?? "/").split("?")[0];
            const file = await findStaticFile(pathname);

            if (file) {
                const headers = {
                    "content-type": MIME_TYPES[extname(file).toLowerCase()] ?? "application/octet-stream",
                    // Hashed build assets never change; everything else gets a short TTL.
                    "cache-control": pathname.startsWith("/assets/")
                        ? "public, max-age=31536000, immutable"
                        : "public, max-age=3600",
                };
                response.writeHead(200, headers);
                if (request.method === "HEAD") {
                    response.end();
                } else {
                    createReadStream(file).pipe(response);
                }
                return;
            }
        }

        const webResponse = await handler.fetch(toWebRequest(request));
        await sendWebResponse(webResponse, response);
    } catch (error) {
        console.error("Server error:", error);
        if (!response.headersSent) {
            response.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
        }
        response.end("Internal Server Error");
    }
});

server.listen(PORT, HOST, () => {
    console.log(`Syllo listening on http://${HOST}:${PORT}`);
});