import Mime from "@effect/platform-node/Mime";
import { Data, Effect, FileSystem, Option, Path } from "effect";
import { cast } from "effect/Function";
import {
HttpBody,
HttpClient,
HttpClientResponse,
HttpRouter,
HttpServerResponse,
HttpServerRequest,
} from "effect/unstable/http";
import { OtlpTracer } from "effect/unstable/observability";
import {
ATTACHMENTS_ROUTE_PREFIX,
normalizeAttachmentRelativePath,
resolveAttachmentRelativePath,
} from "./attachmentPaths.ts";
import { resolveAttachmentPathById } from "./attachmentStore.ts";
import { resolveStaticDir, ServerConfig } from "./config.ts";
import { decodeOtlpTraceRecords } from "./observability/TraceRecord.ts";
import { BrowserTraceCollector } from "./observability/Services/BrowserTraceCollector.ts";
import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver.ts";
import { ServerAuth } from "./auth/Services/ServerAuth.ts";
import { respondToAuthError } from "./auth/http.ts";
import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts";
const PROJECT_FAVICON_CACHE_CONTROL = "public, max-age=3600";
const FALLBACK_PROJECT_FAVICON_SVG = ``;
const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces";
const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);
export const browserApiCorsLayer = HttpRouter.cors({
allowedMethods: ["GET", "POST", "OPTIONS"],
allowedHeaders: ["authorization", "b3", "traceparent", "content-type"],
maxAge: 600,
});
export function isLoopbackHostname(hostname: string): boolean {
const normalizedHostname = hostname
.trim()
.toLowerCase()
.replace(/^\[(.*)\]$/, "$1");
return LOOPBACK_HOSTNAMES.has(normalizedHostname);
}
export function resolveDevRedirectUrl(devUrl: URL, requestUrl: URL): string {
const redirectUrl = new URL(devUrl.toString());
redirectUrl.pathname = requestUrl.pathname;
redirectUrl.search = requestUrl.search;
redirectUrl.hash = requestUrl.hash;
return redirectUrl.toString();
}
const requireAuthenticatedRequest = Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest;
const serverAuth = yield* ServerAuth;
yield* serverAuth.authenticateHttpRequest(request);
});
export const serverEnvironmentRouteLayer = HttpRouter.add(
"GET",
"/.well-known/t3/environment",
Effect.gen(function* () {
const descriptor = yield* Effect.service(ServerEnvironment).pipe(
Effect.flatMap((serverEnvironment) => serverEnvironment.getDescriptor),
);
return HttpServerResponse.jsonUnsafe(descriptor, { status: 200 });
}),
);
class DecodeOtlpTraceRecordsError extends Data.TaggedError("DecodeOtlpTraceRecordsError")<{
readonly cause: unknown;
readonly bodyJson: OtlpTracer.TraceData;
}> {}
export const otlpTracesProxyRouteLayer = HttpRouter.add(
"POST",
OTLP_TRACES_PROXY_PATH,
Effect.gen(function* () {
yield* requireAuthenticatedRequest;
const request = yield* HttpServerRequest.HttpServerRequest;
const config = yield* ServerConfig;
const otlpTracesUrl = config.otlpTracesUrl;
const browserTraceCollector = yield* BrowserTraceCollector;
const httpClient = yield* HttpClient.HttpClient;
const bodyJson = cast(yield* request.json);
yield* Effect.try({
try: () => decodeOtlpTraceRecords(bodyJson),
catch: (cause) => new DecodeOtlpTraceRecordsError({ cause, bodyJson }),
}).pipe(
Effect.flatMap((records) => browserTraceCollector.record(records)),
Effect.catch((cause) =>
Effect.logWarning("Failed to decode browser OTLP traces", {
cause,
bodyJson,
}),
),
);
if (otlpTracesUrl === undefined) {
return HttpServerResponse.empty({ status: 204 });
}
return yield* httpClient
.post(otlpTracesUrl, {
body: HttpBody.jsonUnsafe(bodyJson),
})
.pipe(
Effect.flatMap(HttpClientResponse.filterStatusOk),
Effect.as(HttpServerResponse.empty({ status: 204 })),
Effect.tapError((cause) =>
Effect.logWarning("Failed to export browser OTLP traces", {
cause,
otlpTracesUrl,
}),
),
Effect.catch(() =>
Effect.succeed(HttpServerResponse.text("Trace export failed.", { status: 502 })),
),
);
}).pipe(Effect.catchTag("AuthError", respondToAuthError)),
);
export const attachmentsRouteLayer = HttpRouter.add(
"GET",
`${ATTACHMENTS_ROUTE_PREFIX}/*`,
Effect.gen(function* () {
yield* requireAuthenticatedRequest;
const request = yield* HttpServerRequest.HttpServerRequest;
const url = HttpServerRequest.toURL(request);
if (Option.isNone(url)) {
return HttpServerResponse.text("Bad Request", { status: 400 });
}
const config = yield* ServerConfig;
const rawRelativePath = url.value.pathname.slice(ATTACHMENTS_ROUTE_PREFIX.length);
const normalizedRelativePath = normalizeAttachmentRelativePath(rawRelativePath);
if (!normalizedRelativePath) {
return HttpServerResponse.text("Invalid attachment path", { status: 400 });
}
const isIdLookup =
!normalizedRelativePath.includes("/") && !normalizedRelativePath.includes(".");
const filePath = isIdLookup
? resolveAttachmentPathById({
attachmentsDir: config.attachmentsDir,
attachmentId: normalizedRelativePath,
})
: resolveAttachmentRelativePath({
attachmentsDir: config.attachmentsDir,
relativePath: normalizedRelativePath,
});
if (!filePath) {
return HttpServerResponse.text(isIdLookup ? "Not Found" : "Invalid attachment path", {
status: isIdLookup ? 404 : 400,
});
}
const fileSystem = yield* FileSystem.FileSystem;
const fileInfo = yield* fileSystem
.stat(filePath)
.pipe(Effect.catch(() => Effect.succeed(null)));
if (!fileInfo || fileInfo.type !== "File") {
return HttpServerResponse.text("Not Found", { status: 404 });
}
return yield* HttpServerResponse.file(filePath, {
status: 200,
headers: {
"Cache-Control": "public, max-age=31536000, immutable",
},
}).pipe(
Effect.catch(() =>
Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })),
),
);
}).pipe(Effect.catchTag("AuthError", respondToAuthError)),
);
export const projectFaviconRouteLayer = HttpRouter.add(
"GET",
"/api/project-favicon",
Effect.gen(function* () {
yield* requireAuthenticatedRequest;
const request = yield* HttpServerRequest.HttpServerRequest;
const url = HttpServerRequest.toURL(request);
if (Option.isNone(url)) {
return HttpServerResponse.text("Bad Request", { status: 400 });
}
const projectCwd = url.value.searchParams.get("cwd");
if (!projectCwd) {
return HttpServerResponse.text("Missing cwd parameter", { status: 400 });
}
const faviconResolver = yield* ProjectFaviconResolver;
const faviconFilePath = yield* faviconResolver.resolvePath(projectCwd);
if (!faviconFilePath) {
return HttpServerResponse.text(FALLBACK_PROJECT_FAVICON_SVG, {
status: 200,
contentType: "image/svg+xml",
headers: {
"Cache-Control": PROJECT_FAVICON_CACHE_CONTROL,
},
});
}
return yield* HttpServerResponse.file(faviconFilePath, {
status: 200,
headers: {
"Cache-Control": PROJECT_FAVICON_CACHE_CONTROL,
},
}).pipe(
Effect.catch(() =>
Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })),
),
);
}).pipe(Effect.catchTag("AuthError", respondToAuthError)),
);
export const staticAndDevRouteLayer = HttpRouter.add(
"GET",
"*",
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest;
const url = HttpServerRequest.toURL(request);
if (Option.isNone(url)) {
return HttpServerResponse.text("Bad Request", { status: 400 });
}
const config = yield* ServerConfig;
if (config.devUrl && isLoopbackHostname(url.value.hostname)) {
return HttpServerResponse.redirect(resolveDevRedirectUrl(config.devUrl, url.value), {
status: 302,
});
}
const staticDir = config.staticDir ?? (config.devUrl ? yield* resolveStaticDir() : undefined);
if (!staticDir) {
return HttpServerResponse.text("No static directory configured and no dev URL set.", {
status: 503,
});
}
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const staticRoot = path.resolve(staticDir);
const staticRequestPath = url.value.pathname === "/" ? "/index.html" : url.value.pathname;
const rawStaticRelativePath = staticRequestPath.replace(/^[/\\]+/, "");
const hasRawLeadingParentSegment = rawStaticRelativePath.startsWith("..");
const staticRelativePath = path.normalize(rawStaticRelativePath).replace(/^[/\\]+/, "");
const hasPathTraversalSegment = staticRelativePath.startsWith("..");
if (
staticRelativePath.length === 0 ||
hasRawLeadingParentSegment ||
hasPathTraversalSegment ||
staticRelativePath.includes("\0")
) {
return HttpServerResponse.text("Invalid static file path", { status: 400 });
}
const isWithinStaticRoot = (candidate: string) =>
candidate === staticRoot ||
candidate.startsWith(staticRoot.endsWith(path.sep) ? staticRoot : `${staticRoot}${path.sep}`);
let filePath = path.resolve(staticRoot, staticRelativePath);
if (!isWithinStaticRoot(filePath)) {
return HttpServerResponse.text("Invalid static file path", { status: 400 });
}
const ext = path.extname(filePath);
if (!ext) {
filePath = path.resolve(filePath, "index.html");
if (!isWithinStaticRoot(filePath)) {
return HttpServerResponse.text("Invalid static file path", { status: 400 });
}
}
const fileInfo = yield* fileSystem
.stat(filePath)
.pipe(Effect.catch(() => Effect.succeed(null)));
if (!fileInfo || fileInfo.type !== "File") {
const indexPath = path.resolve(staticRoot, "index.html");
const indexData = yield* fileSystem
.readFile(indexPath)
.pipe(Effect.catch(() => Effect.succeed(null)));
if (!indexData) {
return HttpServerResponse.text("Not Found", { status: 404 });
}
return HttpServerResponse.uint8Array(indexData, {
status: 200,
contentType: "text/html; charset=utf-8",
});
}
const contentType = Mime.getType(filePath) ?? "application/octet-stream";
const data = yield* fileSystem
.readFile(filePath)
.pipe(Effect.catch(() => Effect.succeed(null)));
if (!data) {
return HttpServerResponse.text("Internal Server Error", { status: 500 });
}
return HttpServerResponse.uint8Array(data, {
status: 200,
contentType,
});
}),
);