UNPKG

@mojojs/core

Version:

Real-time web framework

178 lines 6.65 kB
import crypto from 'node:crypto'; import Path from '@mojojs/path'; /** * Static file server class. */ export class Static { constructor() { /** * Subdirectory used for all static assets. */ this.assetDir = 'assets'; /** * Prefix to use for all static files. */ this.prefix = '/static'; /** * Directories to serve static files from, first one has the highest precedence. */ this.publicPaths = [Path.currentFile().sibling('..', 'vendor', 'public').toString()]; } /** * Get static asset path. */ assetPath(path) { if (path.startsWith('/') === false) path = '/' + path; const assets = this._assets; if (assets !== undefined && assets[path] !== undefined) return this.prefix + '/' + this.assetDir + assets[path]; return this.prefix + '/' + this.assetDir + path; } /** * Serve static files. */ async dispatch(ctx) { const { req } = ctx; const unsafePath = req.path; if (unsafePath.startsWith(this.prefix) === false) return false; const { method } = req; if (method !== 'GET' && method !== 'HEAD') return false; const relative = new Path(...unsafePath.replace(this.prefix, '').split('/')); const relativePath = relative.toString(); if (relativePath !== relative.normalize().toString() || relativePath.startsWith('..')) return false; for (const dir of this.publicPaths) { const file = new Path(dir, relativePath); if ((await file.isReadable()) !== true || (await file.stat()).isDirectory() === true) continue; // Development assets will be rebuilt a lot, do not let browsers cache them if (ctx.app.mode === 'development' && relativePath.startsWith(this.assetDir)) { ctx.res.set('Cache-Control', 'no-cache'); } await this.serveFile(ctx, file); return true; } return false; } /** * Get static file path with prefix. */ filePath(path) { if (path.startsWith('/') === false) path = '/' + path; return this.prefix + path; } /** * Check freshness of request by comparing the `If-None-Match` and `If-Modified-Since` request headers to the `ETag` * and `Last-Modified` response headers. */ isFresh(ctx, options = {}) { const { etag, lastModified } = options; const { req, res } = ctx; if (etag !== undefined) res.set('Etag', `"${etag}"`); if (lastModified !== undefined) res.set('Last-Modified', lastModified.toUTCString()); // If-None-Match const ifNoneMatch = req.get('If-None-Match'); if (etag !== undefined && ifNoneMatch !== null) { const etags = ifNoneMatch.split(/,\s+/).map(value => value.replaceAll('"', '')); for (const match of etags) { if (match === etag) return true; } } // If-Modified-Since const ifModifiedSince = req.get('If-Modified-Since'); if (options.lastModified !== undefined && ifModifiedSince !== null) { const epoch = Date.parse(ifModifiedSince); if (isNaN(epoch)) return false; if (epoch > options.lastModified.getTime()) return true; } return false; } /** * Serve a specific file. */ async serveFile(ctx, file) { const { app } = ctx; if ((await app.hooks.runHook('static:before', ctx, file)) === true) return; const stat = await file.stat(); const length = stat.size; if (typeof length !== 'number') return await this._rangeNotSatisfiable(ctx); const type = app.mime.extType(file.extname().replace('.', '')) ?? 'application/octet-stream'; const range = ctx.req.get('Range'); // Last-Modified and Etag const { res } = ctx; res.set('Accept-Ranges', 'bytes'); const lastModified = stat.mtime; const etag = crypto.createHash('md5').update(lastModified.getTime().toString()).digest('hex'); if (this.isFresh(ctx, { etag, lastModified })) { await res.status(304).send(); return; } // Entire file if (range === null) { await res.status(200).length(length).type(type).send(file.createReadStream()); return; } // Empty file if (length === 0) return await this._rangeNotSatisfiable(ctx); // Invalid range const bytes = range.match(/^bytes=(\d+)?-(\d+)?/); if (bytes === null) return await this._rangeNotSatisfiable(ctx); let start = parseInt(bytes[1]); if (isNaN(start)) start = 0; let end = parseInt(bytes[2]); if (isNaN(end)) end = length - 1; if (start > end) return await this._rangeNotSatisfiable(ctx); // Range await res .status(206) .length(end - start + 1) .type(type) .set('Content-Range', `bytes ${start}-${end}/${length}`) .send(file.createReadStream({ start, end })); } /** * Prepare static assets. */ async warmup() { const assets = (this._assets = {}); const { assetDir } = this; for (const publicPath of this.publicPaths) { const assetPath = new Path(publicPath, assetDir); if ((await assetPath.exists()) === false) continue; for await (const assetFile of assetPath.list({ recursive: true })) { const parts = assetPath.relative(assetFile).toArray(); const len = parts.length; const prefix = len > 1 ? parts.slice(0, len - 1).join('/') : ''; const match = parts[len - 1].match(/^([^.]+)\.([^.]+)\.(.+)$/); if (match === null) continue; const name = `${match[1]}.${match[3]}`; const short = prefix === '' ? `/${name}` : `/${prefix}/${name}`; const long = '/' + parts.join('/'); if (assets[short] === undefined || match[2] === 'development') assets[short] = long; } } } async _rangeNotSatisfiable(ctx) { await ctx.res.status(416).send(); } } //# sourceMappingURL=static.js.map