EdgeAIG's picture
download
raw
12.6 kB
/**
* Serves static files for Effect HTTP applications.
*
* `HttpStaticServer` turns request paths into file responses under a configured
* root directory. It can be used as an application value or mounted onto an
* `HttpRouter`, and it handles index files, optional single-page application
* fallback, MIME type headers, cache-control headers, byte ranges, and
* conditional `304 Not Modified` responses.
*
* @since 4.0.0
*/
import * as Effect from "../../Effect.js";
import * as FileSystem from "../../FileSystem.js";
import * as Layer from "../../Layer.js";
import * as Path from "../../Path.js";
import * as HttpPlatform from "./HttpPlatform.js";
import * as HttpRouter from "./HttpRouter.js";
import * as HttpServerError from "./HttpServerError.js";
import * as HttpServerRequest from "./HttpServerRequest.js";
import * as HttpServerRespondable from "./HttpServerRespondable.js";
import * as HttpServerResponse from "./HttpServerResponse.js";
/**
* Creates an `HttpApp` that serves files from a directory.
*
* **Example** (Serving files from a directory)
*
* ```ts
* import { Effect } from "effect"
* import { HttpStaticServer } from "effect/unstable/http"
*
* const program = Effect.gen(function*() {
* const app = yield* HttpStaticServer.make({ root: "./public" })
* return app
* })
* ```
*
* @category constructors
* @since 4.0.0
*/
export const make = /*#__PURE__*/Effect.fnUntraced(function* (options) {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const platform = yield* HttpPlatform.HttpPlatform;
const resolvedRoot = path.resolve(options.root);
const index = "index" in options ? options.index : "index.html";
const spa = options.spa === true;
const cacheControl = options.cacheControl;
const mimeTypes = {
...defaultMimeTypes,
...options.mimeTypes
};
const setFileHeaders = (response, filePath) => {
let currentResponse = HttpServerResponse.setHeaders(response, {
"Content-Type": resolveMimeType(path, filePath, mimeTypes),
"Accept-Ranges": "bytes"
});
if (cacheControl !== undefined) {
currentResponse = HttpServerResponse.setHeader(currentResponse, "Cache-Control", cacheControl);
}
return currentResponse;
};
const serveFile = Effect.fnUntraced(function* (request, filePath, fileSize) {
const rangeHeader = request.headers["range"];
const shouldEvaluateConditionals = request.headers["if-none-match"] !== undefined || request.headers["if-modified-since"] !== undefined;
let fullResponse;
if (shouldEvaluateConditionals) {
fullResponse = setFileHeaders(yield* handlePlatformError(request, platform.fileResponse(filePath)), filePath);
const conditionalResponse = evaluateConditionalRequest(request, fullResponse);
if (conditionalResponse !== undefined) {
return conditionalResponse;
}
if (rangeHeader === undefined) {
return fullResponse;
}
}
const resolvedFileSize = rangeHeader === undefined ? undefined : fileSize ?? Number((yield* handlePlatformError(request, fileSystem.stat(filePath))).size);
const parsedRange = rangeHeader === undefined || resolvedFileSize === undefined ? undefined : parseRange(rangeHeader, resolvedFileSize);
if (parsedRange === undefined) {
return fullResponse ?? setFileHeaders(yield* handlePlatformError(request, platform.fileResponse(filePath)), filePath);
}
if (parsedRange === "unsatisfiable") {
return HttpServerResponse.empty({
status: 416,
headers: {
"Content-Range": `bytes */${resolvedFileSize}`
}
});
}
let response = setFileHeaders(yield* handlePlatformError(request, platform.fileResponse(filePath, {
status: 206,
offset: parsedRange.start,
bytesToRead: parsedRange.end - parsedRange.start + 1
})), filePath);
response = HttpServerResponse.setHeader(response, "Content-Range", `bytes ${parsedRange.start}-${parsedRange.end}/${resolvedFileSize}`);
return response;
});
// @effect-diagnostics-next-line returnEffectInGen:off
return HttpServerRequest.HttpServerRequest.use(request => {
const resolvedPath = resolveFilePath(path, resolvedRoot, request.url);
if (resolvedPath === undefined) {
return Effect.fail(toRouteNotFoundError(request));
}
return Effect.matchEffect(fileSystem.stat(resolvedPath), {
onFailure: error => error.reason._tag === "NotFound" && spa && index !== undefined && path.extname(resolvedPath) === "" && acceptsHtml(request.headers["accept"]) ? serveFile(request, path.join(resolvedRoot, index)) : error.reason._tag === "NotFound" ? Effect.fail(toRouteNotFoundError(request)) : Effect.fail(toInternalServerError(request, error)),
onSuccess(info) {
if (info.type === "File") {
return serveFile(request, resolvedPath, Number(info.size));
}
if (info.type === "Directory" && index !== undefined) {
return serveFile(request, path.join(resolvedPath, index));
}
return Effect.fail(toRouteNotFoundError(request));
}
});
});
});
/**
* Creates a layer that mounts static files on an `HttpRouter`.
*
* **Example** (Mounting static files on a router)
*
* ```ts
* import { Layer } from "effect"
* import { HttpRouter, HttpServerResponse, HttpStaticServer } from "effect/unstable/http"
*
* const ApiLayer = HttpRouter.add("GET", "/health", HttpServerResponse.text("ok"))
*
* const StaticFilesLayer = HttpStaticServer.layer({
* root: "./public",
* prefix: "/static"
* })
*
* const AppLayer = Layer.mergeAll(ApiLayer, StaticFilesLayer)
* ```
*
* @category layers
* @since 4.0.0
*/
export const layer = options => Layer.effectDiscard(Effect.gen(function* () {
const router = yield* HttpRouter.HttpRouter;
const handler = (yield* make(options)).pipe(Effect.catch(HttpServerRespondable.toResponse));
if (options.prefix !== undefined) {
yield* router.prefixed(options.prefix).add("GET", "/*", handler);
return;
}
yield* router.add("GET", "/*", handler);
}));
const defaultMimeTypes = {
html: "text/html; charset=utf-8",
htm: "text/html; charset=utf-8",
css: "text/css; charset=utf-8",
js: "text/javascript; charset=utf-8",
mjs: "text/javascript; charset=utf-8",
json: "application/json; charset=utf-8",
xml: "application/xml; charset=utf-8",
txt: "text/plain; charset=utf-8",
csv: "text/csv; charset=utf-8",
md: "text/markdown; charset=utf-8",
yaml: "text/yaml; charset=utf-8",
yml: "text/yaml; charset=utf-8",
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
svg: "image/svg+xml; charset=utf-8",
ico: "image/x-icon",
webp: "image/webp",
avif: "image/avif",
woff: "font/woff",
woff2: "font/woff2",
ttf: "font/ttf",
otf: "font/otf",
eot: "application/vnd.ms-fontobject",
mp3: "audio/mpeg",
mp4: "video/mp4",
webm: "video/webm",
ogg: "audio/ogg",
wav: "audio/wav",
flac: "audio/flac",
aac: "audio/aac",
pdf: "application/pdf",
zip: "application/zip",
gz: "application/gzip",
wasm: "application/wasm",
map: "application/json",
webmanifest: "application/manifest+json"
};
const stripQueryString = url => {
const queryIndex = url.indexOf("?");
return queryIndex === -1 ? url : url.slice(0, queryIndex);
};
const resolveMimeType = (path, filePath, mimeTypes) => {
const extension = path.extname(filePath).toLowerCase();
if (extension.length <= 1) {
return "application/octet-stream";
}
return mimeTypes[extension.slice(1)] ?? "application/octet-stream";
};
const parseInteger = value => {
if (!/^\d+$/.test(value)) {
return undefined;
}
const parsed = Number(value);
return Number.isSafeInteger(parsed) ? parsed : undefined;
};
const parseRange = (header, fileSize) => {
const value = header.trim();
if (!value.toLowerCase().startsWith("bytes=")) {
return undefined;
}
const rangeValue = value.slice(6).trim();
if (rangeValue.length === 0 || rangeValue.includes(",")) {
return undefined;
}
const separatorIndex = rangeValue.indexOf("-");
if (separatorIndex === -1) {
return undefined;
}
const startPart = rangeValue.slice(0, separatorIndex).trim();
const endPart = rangeValue.slice(separatorIndex + 1).trim();
if (startPart === "" && endPart === "") {
return undefined;
}
if (startPart === "") {
const suffixLength = parseInteger(endPart);
if (suffixLength === undefined) {
return undefined;
}
if (suffixLength === 0 || fileSize === 0) {
return "unsatisfiable";
}
return {
start: Math.max(fileSize - suffixLength, 0),
end: fileSize - 1
};
}
const start = parseInteger(startPart);
if (start === undefined) {
return undefined;
}
if (endPart === "") {
if (start >= fileSize) {
return "unsatisfiable";
}
return {
start,
end: fileSize - 1
};
}
const end = parseInteger(endPart);
if (end === undefined) {
return undefined;
}
if (start > end || start >= fileSize) {
return "unsatisfiable";
}
return {
start,
end: Math.min(end, fileSize - 1)
};
};
const resolveFilePath = (path, root, url) => {
const urlPath = stripQueryString(url);
let decodedPath;
try {
decodedPath = decodeURIComponent(urlPath);
} catch {
return undefined;
}
if (decodedPath.includes("\u0000")) {
return undefined;
}
const normalizedPath = path.normalize(decodedPath.startsWith("/") ? decodedPath.slice(1) : decodedPath);
if (normalizedPath === ".." || normalizedPath.startsWith(`..${path.sep}`)) {
return undefined;
}
const resolvedPath = path.join(root, normalizedPath);
const rootPrefix = root.endsWith(path.sep) ? root : `${root}${path.sep}`;
if (resolvedPath !== root && !resolvedPath.startsWith(rootPrefix)) {
return undefined;
}
return resolvedPath;
};
const toRouteNotFoundError = request => new HttpServerError.HttpServerError({
reason: new HttpServerError.RouteNotFound({
request
})
});
const toInternalServerError = (request, cause) => new HttpServerError.HttpServerError({
reason: new HttpServerError.InternalError({
request,
cause
})
});
const handlePlatformError = (request, self) => Effect.catchIf(self, error => error.reason._tag === "NotFound", () => Effect.fail(toRouteNotFoundError(request)), error => Effect.fail(toInternalServerError(request, error)));
const acceptsHtml = accept => accept !== undefined && accept.toLowerCase().includes("text/html");
const stripWeakEtagPrefix = value => {
const trimmed = value.trim();
return /^w\//i.test(trimmed) ? trimmed.slice(2) : trimmed;
};
const matchesIfNoneMatch = (ifNoneMatch, etag) => {
const normalizedEtag = etag === undefined ? undefined : stripWeakEtagPrefix(etag);
for (const candidate of ifNoneMatch.split(",")) {
const value = candidate.trim();
if (value === "") {
continue;
}
if (value === "*") {
return true;
}
if (normalizedEtag !== undefined && stripWeakEtagPrefix(value) === normalizedEtag) {
return true;
}
}
return false;
};
const isNotModifiedSince = (ifModifiedSince, lastModified) => {
if (lastModified === undefined) {
return false;
}
const ifModifiedSinceMs = Date.parse(ifModifiedSince);
if (Number.isNaN(ifModifiedSinceMs)) {
return false;
}
const lastModifiedMs = Date.parse(lastModified);
if (Number.isNaN(lastModifiedMs)) {
return false;
}
return lastModifiedMs <= ifModifiedSinceMs;
};
const notModifiedResponse = response => {
const headers = {};
const etag = response.headers["etag"];
if (etag !== undefined) {
headers["ETag"] = etag;
}
const cacheControl = response.headers["cache-control"];
if (cacheControl !== undefined) {
headers["Cache-Control"] = cacheControl;
}
const lastModified = response.headers["last-modified"];
if (lastModified !== undefined) {
headers["Last-Modified"] = lastModified;
}
return HttpServerResponse.empty({
status: 304,
headers
});
};
const evaluateConditionalRequest = (request, response) => {
const ifNoneMatch = request.headers["if-none-match"];
if (ifNoneMatch !== undefined) {
return matchesIfNoneMatch(ifNoneMatch, response.headers["etag"]) ? notModifiedResponse(response) : undefined;
}
const ifModifiedSince = request.headers["if-modified-since"];
if (ifModifiedSince !== undefined && isNotModifiedSince(ifModifiedSince, response.headers["last-modified"])) {
return notModifiedResponse(response);
}
return undefined;
};
//# sourceMappingURL=HttpStaticServer.js.map

Xet Storage Details

Size:
12.6 kB
·
Xet hash:
26e2fb4feda32ea3874fd81d987fd7b0ae005eaba1eaaa80adeb5bce46f2f8d0

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.