UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

191 lines (190 loc) 6.35 kB
import { $hook, $inject, $module, Alepha, KIND, Primitive, createPrimitive } from "alepha"; import { AlephaServer, ServerRouterProvider } from "alepha/server"; import { createReadStream } from "node:fs"; import { access, readdir, stat } from "node:fs/promises"; import { basename, isAbsolute, join, sep } from "node:path"; import { DateTimeProvider } from "alepha/datetime"; import { $logger } from "alepha/logger"; import { FileDetector } from "alepha/system"; //#region ../../src/server/static/primitives/$serve.ts /** * Create a new static file handler. */ const $serve = (options = {}) => { return createPrimitive(ServePrimitive, options); }; var ServePrimitive = class extends Primitive {}; $serve[KIND] = ServePrimitive; //#endregion //#region ../../src/server/static/providers/ServerStaticProvider.ts var ServerStaticProvider = class { alepha = $inject(Alepha); routerProvider = $inject(ServerRouterProvider); dateTimeProvider = $inject(DateTimeProvider); fileDetector = $inject(FileDetector); log = $logger(); directories = []; configure = $hook({ on: "configure", handler: async () => { await Promise.all(this.alepha.primitives($serve).map((it) => this.createStaticServer(it.options))); } }); async createStaticServer(options) { 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); const files = await this.getAllFiles(root, options.ignoreDotEnvFiles); const routes = await Promise.all(files.map(async (file) => { 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 (options.indexFallback !== false && route.path.endsWith("index.html")) this.routerProvider.createRoute({ silent: options.silent, path: route.path.replace(/index\.html$/, ""), handler: route.handler }); } this.directories.push({ options, files: files.map((file) => file.replace(root, "").replace(/\\/g, "/")) }); if (options.historyApiFallback) this.routerProvider.createRoute({ silent: options.silent, path: join(prefix, "*").replace(/\\/g, "/"), handler: async (request) => { const { reply } = request; if (request.url.pathname.includes(".")) { 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((resolve, reject) => { const stream = createReadStream(join(root, "index.html")); stream.on("open", () => { resolve(stream); }); stream.on("error", (err) => { reject(err); }); }); } }); } async createFileHandler(filepath, options) { 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) => { const { headers, reply } = request; let path = filepath; 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((resolve, reject) => { const stream = createReadStream(path); stream.on("open", () => { resolve(stream); }); stream.on("error", (err) => { reject(err); }); }); }; } getCacheFileTypes() { return [ ".js", ".css", ".woff", ".woff2", ".ttf", ".eot", ".otf", ".jpg", ".jpeg", ".png", ".svg", ".gif" ]; } getCacheControl(filename, options) { 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") }; } async getAllFiles(dir, ignoreDotEnvFiles = true) { const entries = await readdir(dir, { withFileTypes: true }); return (await Promise.all(entries.map((dirent) => { if (ignoreDotEnvFiles && dirent.name.startsWith(".")) return []; const fullPath = join(dir, dirent.name); return dirent.isDirectory() ? this.getAllFiles(fullPath) : fullPath; }))).flat(); } }; //#endregion //#region ../../src/server/static/index.ts /** * Static file serving. * * **Features:** * - Serve static files from directory * * @module alepha.server.static */ const AlephaServerStatic = $module({ name: "alepha.server.static", primitives: [$serve], services: [AlephaServer, ServerStaticProvider] }); //#endregion export { $serve, AlephaServerStatic, ServePrimitive, ServerStaticProvider }; //# sourceMappingURL=index.js.map