Scaffold Docker-only Node 24 + TypeScript EJS web backend
This commit is contained in:
43
src/app.test.ts
Normal file
43
src/app.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import assert from "node:assert/strict";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { after, before, test } from "node:test";
|
||||
import { createApp } from "./app.ts";
|
||||
import { contentTypeFor, resolveStaticPath } from "./static.ts";
|
||||
|
||||
const server = createApp();
|
||||
let base = "";
|
||||
|
||||
before(async () => {
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
base = `http://localhost:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
after(() => server.close());
|
||||
|
||||
test("serves the home page as HTML", async () => {
|
||||
const res = await fetch(base + "/");
|
||||
assert.equal(res.status, 200);
|
||||
assert.match(res.headers.get("content-type") ?? "", /text\/html/);
|
||||
assert.match(await res.text(), /Plainpages/);
|
||||
});
|
||||
|
||||
test("serves static CSS", async () => {
|
||||
const res = await fetch(base + "/public/css/style.css");
|
||||
assert.equal(res.status, 200);
|
||||
assert.match(res.headers.get("content-type") ?? "", /text\/css/);
|
||||
});
|
||||
|
||||
test("returns 404 for unknown routes", async () => {
|
||||
const res = await fetch(base + "/missing");
|
||||
assert.equal(res.status, 404);
|
||||
});
|
||||
|
||||
test("resolveStaticPath blocks traversal, allows nested files", () => {
|
||||
assert.equal(resolveStaticPath("/srv/public", "../app.ts"), null);
|
||||
assert.equal(resolveStaticPath("/srv/public", "css/style.css"), "/srv/public/css/style.css");
|
||||
});
|
||||
|
||||
test("contentTypeFor maps known and unknown extensions", () => {
|
||||
assert.match(contentTypeFor("a.css"), /text\/css/);
|
||||
assert.equal(contentTypeFor("a.bin"), "application/octet-stream");
|
||||
});
|
||||
49
src/app.ts
Normal file
49
src/app.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import * as ejs from "ejs";
|
||||
import { serveStatic } from "./static.ts";
|
||||
|
||||
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
export interface AppOptions {
|
||||
publicDir?: string;
|
||||
viewsDir?: string;
|
||||
}
|
||||
|
||||
export function createApp(options: AppOptions = {}): Server {
|
||||
const publicDir = options.publicDir ?? join(rootDir, "public");
|
||||
const viewsDir = options.viewsDir ?? join(rootDir, "views");
|
||||
|
||||
const render = (view: string, data: Record<string, unknown>): Promise<string> =>
|
||||
ejs.renderFile(join(viewsDir, `${view}.ejs`), data);
|
||||
|
||||
return createServer(async (req, res) => {
|
||||
try {
|
||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||
res.writeHead(405, { "content-type": "text/plain; charset=utf-8" }).end("Method Not Allowed");
|
||||
return;
|
||||
}
|
||||
|
||||
const { pathname } = new URL(req.url ?? "/", "http://localhost");
|
||||
|
||||
if (pathname.startsWith("/public/")) {
|
||||
await serveStatic(publicDir, pathname.slice("/public/".length), res);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === "/") {
|
||||
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
||||
res.end(await render("index", { title: "Plainpages" }));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404, { "content-type": "text/html; charset=utf-8" });
|
||||
res.end(await render("404", { title: "Not found" }));
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
if (!res.headersSent) res.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
|
||||
res.end("Internal Server Error");
|
||||
}
|
||||
});
|
||||
}
|
||||
7
src/server.ts
Normal file
7
src/server.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createApp } from "./app.ts";
|
||||
|
||||
const port = Number(process.env["PORT"] ?? 3000);
|
||||
|
||||
createApp().listen(port, () => {
|
||||
console.log(`Listening on http://localhost:${port}`);
|
||||
});
|
||||
55
src/static.ts
Normal file
55
src/static.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { createReadStream } from "node:fs";
|
||||
import { stat } from "node:fs/promises";
|
||||
import type { ServerResponse } from "node:http";
|
||||
import { extname, isAbsolute, join, relative } from "node:path";
|
||||
|
||||
const contentTypes: Record<string, string> = {
|
||||
".css": "text/css; charset=utf-8",
|
||||
".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",
|
||||
".png": "image/png",
|
||||
".svg": "image/svg+xml",
|
||||
".txt": "text/plain; charset=utf-8",
|
||||
".webp": "image/webp",
|
||||
".woff2": "font/woff2",
|
||||
};
|
||||
|
||||
export function contentTypeFor(filePath: string): string {
|
||||
return contentTypes[extname(filePath).toLowerCase()] ?? "application/octet-stream";
|
||||
}
|
||||
|
||||
// Resolves a request path inside `dir`, or null if it would escape (path traversal).
|
||||
export function resolveStaticPath(dir: string, requestedPath: string): string | null {
|
||||
const filePath = join(dir, requestedPath);
|
||||
const rel = relative(dir, filePath);
|
||||
return rel.startsWith("..") || isAbsolute(rel) ? null : filePath;
|
||||
}
|
||||
|
||||
function plain(res: ServerResponse, status: number, body: string): void {
|
||||
res.writeHead(status, { "content-type": "text/plain; charset=utf-8" }).end(body);
|
||||
}
|
||||
|
||||
export async function serveStatic(dir: string, requestedPath: string, res: ServerResponse): Promise<void> {
|
||||
let decoded: string;
|
||||
try {
|
||||
decoded = decodeURIComponent(requestedPath);
|
||||
} catch {
|
||||
return plain(res, 400, "Bad Request");
|
||||
}
|
||||
|
||||
const filePath = resolveStaticPath(dir, decoded);
|
||||
if (filePath === null) return plain(res, 403, "Forbidden");
|
||||
|
||||
try {
|
||||
const info = await stat(filePath);
|
||||
if (!info.isFile()) return plain(res, 404, "Not Found");
|
||||
res.writeHead(200, { "content-length": info.size, "content-type": contentTypeFor(filePath) });
|
||||
createReadStream(filePath).pipe(res);
|
||||
} catch {
|
||||
plain(res, 404, "Not Found");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user