alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
265 lines (229 loc) • 7.84 kB
text/typescript
import { createReadStream } from "node:fs";
import { access, readdir, stat } from "node:fs/promises";
import { basename, isAbsolute, join, sep } from "node:path";
import type { Readable as NodeStream } from "node:stream";
import { $hook, $inject, Alepha } from "alepha";
import { DateTimeProvider } from "alepha/datetime";
import { $logger } from "alepha/logger";
import { type ServerHandler, ServerRouterProvider } from "alepha/server";
import { FileDetector } from "alepha/system";
import { $serve, type ServePrimitiveOptions } from "../primitives/$serve.ts";
export class ServerStaticProvider {
protected readonly alepha = $inject(Alepha);
protected readonly routerProvider = $inject(ServerRouterProvider);
protected readonly dateTimeProvider = $inject(DateTimeProvider);
protected readonly fileDetector = $inject(FileDetector);
protected readonly log = $logger();
protected readonly directories: ServeDirectory[] = [];
protected readonly configure = $hook({
on: "configure",
handler: async () => {
await Promise.all(
this.alepha
.primitives($serve)
.map((it) => this.createStaticServer(it.options)),
);
},
});
public async createStaticServer(
options: ServePrimitiveOptions,
): Promise<void> {
const prefix = options.path ?? "/";
let root = options.root ?? process.cwd();
if (!isAbsolute(root)) {
root = join(process.cwd(), root);
}
this.log.debug("Serve static files", { prefix, root });
await stat(root);
// 1. get all files in the root directory (recursively)
const files = await this.getAllFiles(root, options.ignoreDotEnvFiles);
// 2. create a $route for each file (yes, this could be a lot of routes)
const routes = await Promise.all(
files.map(async (file) => {
// Normalize to forward slashes for URL paths
const urlPath = file.replace(root, "").replace(/\\/g, "/");
const routePath = `${prefix}${encodeURI(urlPath)}`.replace(/\/+/g, "/");
const filePath = join(root, urlPath.replace(/\//g, sep));
this.log.trace(`Mount ${routePath} -> ${filePath}`);
return {
silent: options.silent,
path: routePath,
handler: await this.createFileHandler(filePath, options),
};
}),
);
for (const route of routes) {
this.routerProvider.createRoute(route);
// if route is for index.html, also create a route without it
// e.g. /my/path/index.html -> /my/path/
if (
options.indexFallback !== false &&
route.path.endsWith("index.html")
) {
this.routerProvider.createRoute({
silent: options.silent,
path: route.path.replace(/index\.html$/, ""),
handler: route.handler,
});
}
}
// 3. store the directory info for reference
this.directories.push({
options,
files: files.map((file) => file.replace(root, "").replace(/\\/g, "/")),
});
// bonus! for SPAs, handle history API fallback
if (options.historyApiFallback) {
// meaning all unmatched routes should serve index.html
this.routerProvider.createRoute({
silent: options.silent,
path: join(prefix, "*").replace(/\\/g, "/"),
handler: async (request) => {
const { reply } = request;
if (request.url.pathname.includes(".")) {
// If the request is for a file (e.g., /style.css), do not fall back
reply.headers["content-type"] = "text/plain";
reply.body = "Not Found";
reply.status = 404;
return;
}
reply.headers["content-type"] = "text/html";
reply.status = 200;
return new Promise<any>((resolve, reject) => {
const stream = createReadStream(join(root, "index.html"));
stream.on("open", () => {
resolve(stream);
});
stream.on("error", (err) => {
reject(err);
});
});
},
});
}
}
public async createFileHandler(
filepath: string,
options: ServePrimitiveOptions,
): Promise<ServerHandler> {
const filename = basename(filepath);
const hasGzip = await access(`${filepath}.gz`)
.then(() => true)
.catch(() => false);
const hasBr = await access(`${filepath}.br`)
.then(() => true)
.catch(() => false);
const fileStat = await stat(filepath);
const lastModified = fileStat.mtime.toUTCString();
const etag = `"${fileStat.size}-${fileStat.mtime.getTime()}"`;
const contentType = this.fileDetector.getContentType(filename);
const cacheControl = this.getCacheControl(filename, options);
return async (request): Promise<NodeStream | undefined> => {
const { headers, reply } = request;
let path = filepath;
// 01/26 - when calling "/directory", redirect to "/directory/"
if (
options.path &&
options.path === request.url.pathname &&
!options.path.endsWith("/")
) {
reply.redirect(`${options.path}/`, 301);
return;
}
const encoding = headers["accept-encoding"];
if (encoding) {
if (hasBr && encoding.includes("br")) {
reply.headers["content-encoding"] = "br";
path += ".br";
} else if (hasGzip && encoding.includes("gzip")) {
reply.headers["content-encoding"] = "gzip";
path += ".gz";
}
}
reply.headers["content-type"] = contentType;
reply.headers["accept-ranges"] = "bytes";
reply.headers["last-modified"] = lastModified;
if (cacheControl) {
reply.headers["cache-control"] =
`public, max-age=${cacheControl.maxAge}`;
if (cacheControl.immutable) {
reply.headers["cache-control"] += ", immutable";
}
}
reply.headers.etag = etag;
if (
headers["if-none-match"] === etag ||
headers["if-modified-since"] === lastModified
) {
reply.status = 304;
return;
}
return new Promise<any>((resolve, reject) => {
const stream = createReadStream(path);
stream.on("open", () => {
resolve(stream);
});
stream.on("error", (err) => {
reject(err);
});
});
};
}
protected getCacheFileTypes(): string[] {
return [
".js",
".css",
".woff",
".woff2",
".ttf",
".eot",
".otf",
".jpg",
".jpeg",
".png",
".svg",
".gif",
];
}
protected getCacheControl(
filename: string,
options: ServePrimitiveOptions,
): { maxAge: number; immutable: boolean } | undefined {
if (!options.cacheControl) {
return;
}
const fileTypes =
options.cacheControl.fileTypes ?? this.getCacheFileTypes();
for (const type of fileTypes) {
if (filename.endsWith(type)) {
return {
immutable: options.cacheControl.immutable ?? true,
maxAge: this.dateTimeProvider
.duration(options.cacheControl.maxAge ?? [30, "days"])
.as("seconds"),
};
}
}
}
public async getAllFiles(
dir: string,
ignoreDotEnvFiles = true,
): Promise<string[]> {
const entries = await readdir(dir, { withFileTypes: true });
const files = await Promise.all(
entries.map((dirent) => {
// skip .env & other dot files
if (ignoreDotEnvFiles && dirent.name.startsWith(".")) {
return [];
}
const fullPath = join(dir, dirent.name);
return dirent.isDirectory() ? this.getAllFiles(fullPath) : fullPath;
}),
);
return files.flat();
}
}
export interface ServeDirectory {
options: ServePrimitiveOptions;
files: string[];
}