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";