Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,10 @@ import { serve } from "@askrjs/node";

const running = await serve(app, {
port: 3000,
assets: { root: "./dist/client" },
assets: {
root: "./dist/client",
exclude: (pathname) => pathname.startsWith("/files/"),
},
});

await running.close();
Expand All @@ -103,7 +106,9 @@ await running.close();
`serve` handles static assets and closes both the HTTP server and the application during shutdown.
When an asset root is configured, extension-bearing `GET` and `HEAD` paths are reserved for static
files: missing files return `404` without falling through to application routing, source maps are not
served, and resolved files must remain inside the configured root. Fingerprinted files under
served, and resolved files must remain inside the configured root. Use `assets.exclude(pathname)` to
bypass static interception for dotted application routes; the predicate receives the decoded URL
pathname. Fingerprinted files under
`/assets/` receive immutable caching; other files receive `no-cache`.
Both `listen` and `serve` bind to `127.0.0.1` by default. A non-loopback
`host` also requires `allowPublicBind: true` so public exposure is explicit. Public listeners should
Expand Down
6 changes: 5 additions & 1 deletion src/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,11 @@ export interface ListenOptions extends NodeHandlerOptions {
/** Options for {@link serve}, extending {@link ListenOptions} with static asset serving and shutdown behavior. */
export interface ServeOptions extends ListenOptions {
/** Serves static files from this directory before falling back to the application. */
readonly assets?: { readonly root: string };
readonly assets?: {
readonly root: string;
/** Returns true when an extension-bearing path must bypass static serving. */
readonly exclude?: (pathname: string) => boolean;
};
/** OS signals that trigger a graceful shutdown; pass `false` to disable automatic shutdown handling. */
readonly signals?: false | readonly NodeJS.Signals[];
}
Expand Down
24 changes: 17 additions & 7 deletions src/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { createServer } from "node:http";
import { extname, resolve, sep } from "node:path";
import { pipeline } from "node:stream";
import type { ServerApp } from "@askrjs/server";
import * as serverHttp from "@askrjs/server/http";
import type { ServeOptions, ServedApplication } from "./contracts.js";
import { formatHostForUrl, handlerOptionsForHost, resolveBindHost } from "./bind.js";
import { createNodeHandler } from "./handler.js";
Expand Down Expand Up @@ -36,6 +37,15 @@ const mimeTypes: Readonly<Record<string, string>> = {
".woff2": "font/woff2",
};

const parseContentType =
(serverHttp as { contentType?: (value: string | null) => string | undefined }).contentType ??
((value: string | null): string | undefined => {
if (!value) return undefined;
const separator = value.indexOf(";");
const type = (separator === -1 ? value : value.slice(0, separator)).trim();
return type ? type.toLowerCase() : undefined;
});

function isAssetPath(pathname: string): boolean {
return extname(pathname) !== "";
}
Expand Down Expand Up @@ -78,12 +88,8 @@ export async function serve(
{
async fetch(request, dispatchOptions) {
const result = await app.fetch(request, dispatchOptions);
const contentType = result.headers.get("content-type");
if (
!result.headers.has("cache-control") &&
contentType !== null &&
/^text\/html(?:;|$)/iu.test(contentType)
) {
const responseContentType = parseContentType(result.headers.get("content-type"));
if (!result.headers.has("cache-control") && responseContentType === "text/html") {
const headers = new Headers(result.headers);
headers.set("cache-control", "no-cache");
return new Response(result.body, {
Expand Down Expand Up @@ -112,7 +118,11 @@ export async function serve(
return;
}
const method = request.method ?? "GET";
if ((method === "GET" || method === "HEAD") && isAssetPath(pathname)) {
if (
(method === "GET" || method === "HEAD") &&
isAssetPath(pathname) &&
!options.assets?.exclude?.(pathname)
) {
const extension = extname(pathname).toLowerCase();
const unresolvedCandidate = resolve(root, `.${pathname}`);
const inside = isWithinRoot(root, unresolvedCandidate);
Expand Down
155 changes: 154 additions & 1 deletion tests/node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { get, request as nodeRequest, type ServerResponse } from "node:http";
import { createConnection } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { basename, join } from "node:path";
import { createRouter, createServerApp } from "@askrjs/server";
import { runAdapterConformance } from "@askrjs/server/testing";
import { describe, expect, it } from "vitest";
Expand Down Expand Up @@ -242,6 +242,40 @@ describe("Node adapter", () => {
expect(() => listen(app, { headersTimeout: 1.5 })).toThrow("non-negative safe integer");
});

it("should reject malformed and oversized raw HTTP input before application dispatch", async () => {
let dispatches = 0;
const server = await listen({
fetch: async () => {
dispatches += 1;
return new Response();
},
});
const address = server.address();
if (!address || typeof address === "string") throw new Error("Expected TCP address");
const sendRaw = (payload: string) =>
new Promise<string>((resolve, reject) => {
const socket = createConnection({ host: "127.0.0.1", port: address.port });
let response = "";
socket.setEncoding("utf8");
socket.on("data", (chunk: string) => (response += chunk));
socket.once("connect", () => socket.end(payload));
socket.once("end", () => resolve(response));
socket.once("error", reject);
});

try {
await expect(sendRaw("GET / HTTP/1.1\r\nMalformed Header\r\n\r\n")).resolves.toMatch(
/^HTTP\/1\.1 400 /,
);
await expect(
sendRaw(`GET / HTTP/1.1\r\nHost: localhost\r\nX-Oversized: ${"x".repeat(20_000)}\r\n\r\n`),
).resolves.toMatch(/^HTTP\/1\.1 431 /);
expect(dispatches).toBe(0);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});

it("should exchange text and binary WebSocket messages", async () => {
const router = createRouter();
router.ws("/echo/{room}", (socket, context) => {
Expand Down Expand Up @@ -1031,6 +1065,50 @@ describe("serve", () => {
}
});

it("should let excluded dotted routes bypass static serving under concurrency", async () => {
const root = await mkdtemp(join(tmpdir(), "askr-node-excluded-assets-"));
await writeFile(join(root, "app.js"), "asset");
let applicationRequests = 0;
const served = await serve(
{
fetch: async (request) => {
applicationRequests += 1;
return new Response(new URL(request.url).pathname);
},
},
{
assets: {
root,
exclude: (pathname) => pathname.startsWith("/files/"),
},
signals: false,
},
);

try {
const responses = await Promise.all(
Array.from({ length: 30 }, (_, index) =>
fetch(
index % 3 === 0
? `${served.url}/app.js`
: index % 3 === 1
? `${served.url}/files/report.json`
: `${served.url}/files/report%2ejson`,
),
),
);
const bodies = await Promise.all(responses.map((response) => response.text()));

expect(bodies.filter((body) => body === "asset")).toHaveLength(10);
expect(bodies.filter((body) => body === "/files/report.json")).toHaveLength(10);
expect(bodies.filter((body) => body === "/files/report%2ejson")).toHaveLength(10);
expect(applicationRequests).toBe(20);
} finally {
await served.close();
await rm(root, { recursive: true, force: true });
}
});

it("should disable caching given an HTML application response when no policy is authored", async () => {
const served = await serve(
{
Expand Down Expand Up @@ -1072,6 +1150,51 @@ describe("serve", () => {
},
);

it("should contain percent-encoded parent traversal inside the static root", async () => {
const root = await mkdtemp(join(tmpdir(), "askr-node-traversal-root-"));
const outside = await mkdtemp(join(tmpdir(), "askr-node-traversal-outside-"));
await writeFile(join(outside, "secret.txt"), "secret");
const served = await serve(
{ fetch: async () => new Response("application") },
{ assets: { root }, signals: false },
);
const address = served.server.address();
if (!address || typeof address === "string") throw new Error("Expected TCP address");
const requestPath = (path: string) =>
new Promise<{ status: number; body: string }>((resolve, reject) => {
const request = nodeRequest({ host: "127.0.0.1", port: address.port, path }, (response) => {
const chunks: Buffer[] = [];
response.on("data", (chunk: Buffer) => chunks.push(chunk));
response.on("end", () =>
resolve({
status: response.statusCode ?? 0,
body: Buffer.concat(chunks).toString(),
}),
);
});
request.once("error", reject);
request.end();
});

try {
const outsideName = basename(outside);
await expect(requestPath(`/%2e%2e/${outsideName}/secret.txt`)).resolves.toEqual({
status: 404,
body: "Not Found",
});
await expect(requestPath(`/%2e%2e%2f${outsideName}/secret.txt`)).resolves.toEqual({
status: 404,
body: "Not Found",
});
} finally {
await served.close();
await Promise.all([
rm(root, { recursive: true, force: true }),
rm(outside, { recursive: true, force: true }),
]);
}
});

it("should close the application exactly once across concurrent shutdown", async () => {
let closes = 0;
const served = await serve(
Expand All @@ -1082,6 +1205,36 @@ describe("serve", () => {
expect(closes).toBe(1);
});

it("should wait for an in-flight request before completing shutdown", async () => {
let markStarted!: () => void;
let release!: () => void;
const started = new Promise<void>((resolve) => (markStarted = resolve));
const released = new Promise<void>((resolve) => (release = resolve));
const served = await serve(
{
async fetch() {
markStarted();
await released;
return new Response("done");
},
},
{ signals: false },
);
const response = fetch(served.url);
await started;
let closed = false;
const closing = served.close().then(() => {
closed = true;
});
await Promise.resolve();
expect(closed).toBe(false);

release();
expect(await (await response).text()).toBe("done");
await closing;
expect(closed).toBe(true);
});

it("should close the application given a server when it was already stopped externally", async () => {
let closes = 0;
const served = await serve(
Expand Down