From 82323c91589f352f2d4f92314dc2bd1c04ff25e5 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Sun, 2 Feb 2025 10:03:55 +0100 Subject: [PATCH] Add development HTTP server Add an HTTP server for development purposes that replies to requests with both the static resources and on the fly generated pages. This serves the following needs. - Modern module scripts requires an origin supporting CORS policies, which is not supported when pages are read from disk by the browser. - Provide a way for implemting automatic reloading of pages when changes are made to the source files. - Act as the starting point for making interactive features such as comments and analytics. --- cli.ts | 21 ++++++++ utils/http-server.test.ts | 97 +++++++++++++++++++++++++++++++++++++ utils/http-server.ts | 82 +++++++++++++++++++++++++++++++ utils/resolve-refs.test.tsx | 2 +- 4 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 utils/http-server.test.ts create mode 100644 utils/http-server.ts diff --git a/cli.ts b/cli.ts index cd48c1a..68cf8ed 100644 --- a/cli.ts +++ b/cli.ts @@ -4,6 +4,7 @@ import { prettify, htmlDocument } from "antihtml"; import { pages } from "./content/pages.js"; import type { Page } from "./content/types.js"; import { resolveRefs } from "./utils/resolve-refs.js"; +import { createServer } from "./utils/http-server.js"; function pageToHtml(page: Page) { if (!page.ref.startsWith("/")) { @@ -59,9 +60,27 @@ function build() { } } +function serve() { + const resources = assembleResources(); + const server = createServer( + (ref) => { + const resource = resources.get(ref); + if (resource === undefined) + return undefined; + if (typeof resource === "string") + return fs.readFileSync(resource); + return Buffer.from(pageToHtml(resource)); + } + ); + + server.listen(8080); + console.log("Listening on http://localhost:8080"); +} + function printUsage() { console.log("Usage: cli.js "); console.log(" build - Copy resources and generated pages to build directory."); + console.log(" serve - Host website on localhost:8080 for development purposes."); } function main(runtime: string, script: string, args: string[]) { @@ -74,6 +93,8 @@ function main(runtime: string, script: string, args: string[]) { const [command, ...commandArgs] = args; if (command === "build") { build(); + } else if (command === "serve") { + serve(); } else { console.log(`Error: Unkown sub-command ${command}`); printUsage(); diff --git a/utils/http-server.test.ts b/utils/http-server.test.ts new file mode 100644 index 0000000..4090526 --- /dev/null +++ b/utils/http-server.test.ts @@ -0,0 +1,97 @@ +import * as assert from "node:assert/strict"; +import { after, before, suite, test } from "node:test"; +import * as http from "node:http"; +import { createServer } from "./http-server.js"; +import { once } from "node:events"; +import type { AddressInfo } from "node:net"; + +suite("function createServer", () => { + let server: ReturnType; + let baseUrl: URL; + + before(async () => { + server = createServer( + ref => { + if (ref === "/test.html") return Buffer.from("

Test!

"); + if (ref === "/style.css") return Buffer.from("p { font-weight: bold; }"); + if (ref === "/script.js") return Buffer.from("alert('Hello world!');"); + return undefined; + } + ); + server.listen(0, "localhost"); + await once(server, "listening"); + baseUrl = new URL(`http://localhost:${(server.address() as AddressInfo).port}`) + }); + after(() => { + server.close(); + }) + + async function makeRequest(method: string, ref: string) { + const url = new URL(ref, baseUrl); + const request = http.request(url, { + method, + timeout: 1000, + }); + request.end(); + const response: http.IncomingMessage & { body: Buffer } = (await once(request, "response"))[0]; + response.body = Buffer.concat(await response.toArray()); + return { request, response }; + } + + async function getTest(ref: string, statusCode: number, content: Buffer, mediaType: string) { + const { response } = await makeRequest("GET", ref); + assert.equal(response.statusCode, statusCode); + assert.equal(response.headers["content-type"], mediaType); + assert.equal(Number.parseInt(response.headers["content-length"]!), content.length); + assert.deepEqual(response.body, content); + } + + async function headTest(ref: string, statusCode: number, content: Buffer, mediaType: string) { + const { response } = await makeRequest("HEAD", ref); + assert.equal(response.statusCode, statusCode); + assert.equal(response.headers["content-type"], mediaType); + assert.equal(Number.parseInt(response.headers["content-length"]!), content.length); + assert.deepEqual(response.body, Buffer.alloc(0)); + } + + test("GET /test.html", async () => { + await getTest("/test.html", 200, Buffer.from("

Test!

"), "text/html") + }); + + test("GET /style.css", async () => { + await getTest("/style.css", 200, Buffer.from("p { font-weight: bold; }"), "text/css") + }); + + test("GET /script.js", async () => { + await getTest("/script.js", 200, Buffer.from("alert('Hello world!');"), "text/javascript") + }); + + test("GET /does-not-exist", async () => { + await getTest("/does-not-exist", 404, Buffer.from("404 Not Found"), "text/plain") + }); + + test("HEAD /test.html", async () => { + await headTest("/test.html", 200, Buffer.from("

Test!

"), "text/html") + }); + + test("HEAD /style.css", async () => { + await headTest("/style.css", 200, Buffer.from("p { font-weight: bold; }"), "text/css") + }); + + test("HEAD /script.js", async () => { + await headTest("/script.js", 200, Buffer.from("alert('Hello world!');"), "text/javascript") + }); + + test("HEAD /does-not-exist", async () => { + await headTest("/does-not-exist", 404, Buffer.from("404 Not Found"), "text/plain") + }); + + test("POST /test.html", async () => { + const { response } = await makeRequest("POST", "/test.html"); + const content = Buffer.from("400 Bad Request"); + assert.equal(response.statusCode, 400); + assert.equal(response.headers["content-type"], "text/plain"); + assert.equal(Number.parseInt(response.headers["content-length"]!), content.length); + assert.deepEqual(response.body, content); + }); +}); diff --git a/utils/http-server.ts b/utils/http-server.ts new file mode 100644 index 0000000..3efa14c --- /dev/null +++ b/utils/http-server.ts @@ -0,0 +1,82 @@ +import * as http from "node:http"; +import * as posix from "node:path/posix"; + +function writeResponse( + response: http.ServerResponse, + statusCode: number, + statusMessage: string, + mimeType: string | undefined, + content: Buffer, + suppressContent = false, +) { + response.statusCode = statusCode; + response.statusMessage = statusMessage; + if (mimeType !== undefined) { + response.setHeader("Content-Type", mimeType); + } + response.setHeader("Content-Length", content.length); + if (!suppressContent) { + response.end(content); + } else { + response.end(); + } +}; + +function writeNotFound(response: http.ServerResponse, suppressContent = false) { + writeResponse( + response, + 404, "Not Found", + "text/plain", + Buffer.from("404 Not Found"), + suppressContent, + ); +} + +function writeBadRequest(response: http.ServerResponse, suppressContent = false) { + writeResponse( + response, + 400, "Bad Request", + "text/plain", + Buffer.from("400 Bad Request"), + suppressContent, + ); +} + +const extToMimeType = new Map([ + [".js", "text/javascript"], + [".html", "text/html"], + [".css", "text/css"], +]); + +export function createServer( + get: (ref: string) => Buffer | undefined +) { + const server = http.createServer( + { + joinDuplicateHeaders: true, + // @ts-expect-error missing in type declaration + rejectNonStandardBodyWrites: true, + }, + (request, response) => { + const url = new URL(`http://localhost${request.url}`); + if (request.method === "GET" || request.method === "HEAD") { + const content = get(url.pathname); + const isHead = request.method === "HEAD"; + if (!content) { + writeNotFound(response, isHead); + return; + } + writeResponse( + response, + 200, "OK", + extToMimeType.get(posix.extname(url.pathname)), + content, + isHead, + ) + return; + } + writeBadRequest(response); + }, + ); + return server; +} diff --git a/utils/resolve-refs.test.tsx b/utils/resolve-refs.test.tsx index 2df9845..9e9878c 100644 --- a/utils/resolve-refs.test.tsx +++ b/utils/resolve-refs.test.tsx @@ -1,4 +1,4 @@ -import assert from "node:assert/strict"; +import * as assert from "node:assert/strict"; import { suite, test } from "node:test"; import { resolveRefs } from "./resolve-refs.js"; import type { Element } from "antihtml";