import Mime from "@effect/platform-node/Mime";import { AuthOrchestrationOperateScope, AuthOrchestrationReadScope, EnvironmentHttpApi,} from "@t3tools/contracts";import { isDevProxiedPath } from "@t3tools/shared/devProxy";import { decodeOtlpTraceRecords } from "@t3tools/shared/observability";import * as Data from "effect/Data";import * as Effect from "effect/Effect";import * as FileSystem from "effect/FileSystem";import * as Layer from "effect/Layer";import * as Option from "effect/Option";import * as Path from "effect/Path";import * as Stream from "effect/Stream";import { cast } from "effect/Function";import { HttpBody, HttpClient, HttpClientResponse, HttpMiddleware, HttpRouter, HttpServerResponse, HttpServerRequest, HttpServerRespondable,} from "effect/unstable/http";import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";import { OtlpTracer } from "effect/unstable/observability"; import * as ServerConfig from "./config.ts";import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts";import { ATTACHMENT_UPLOAD_ROUTE_PREFIX, storeAttachmentUpload, validateAttachmentUploadToken,} from "./assets/AttachmentUpload.ts";import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts";import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";import { traceRelayRequest } from "./cloud/traceRelayRequest.ts";import { annotateEnvironmentRequest, failEnvironmentScopeRequired, failEnvironmentAuthInvalid, failEnvironmentInternal,} from "./auth/http.ts";import * as ServerEnvironment from "./environment/ServerEnvironment.ts";import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./httpCors.ts"; const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces";const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);const DESKTOP_RENDERER_ORIGINS = ["t3code://app", "t3code-dev://app"];const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox"; // Types a browser may render as a document if a proxy strips the disposition// header. Downloads of these fall back to octet-stream.const DOWNLOAD_MIME_TYPE_PATTERN = /^[\w!#$&^.+-]+\/[\w!#$&^.+-]+$/;const isSafeDownloadMimeType = (mimeType: string): boolean => DOWNLOAD_MIME_TYPE_PATTERN.test(mimeType) && !/(?:^text\/html$|\/xml(?:$|-)|\+xml$)/i.test(mimeType.trim().toLowerCase());const isSafeInlineVideoMimeType = (mimeType: string): boolean => DOWNLOAD_MIME_TYPE_PATTERN.test(mimeType) && mimeType.toLowerCase().startsWith("video/"); /** RFC 6266 disposition with an ASCII fallback name plus a UTF-8 `filename*`. */export function downloadContentDisposition(fileName?: string): string { if (fileName === undefined) { return "attachment"; } // toWellFormed: encodeURIComponent throws URIError on unpaired surrogates. // eslint-disable-next-line no-control-regex -- Header filenames must strip ASCII controls. const sanitized = fileName.toWellFormed().replace(/[\u0000-\u001f"\\]/g, "_"); const asciiFallback = sanitized.replace(/[^\u0020-\u007e]/g, "_"); const needsExtended = asciiFallback !== sanitized; const extendedName = encodeURIComponent(sanitized).replace( /['()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`, ); return `attachment; filename="${asciiFallback}"${ needsExtended ? `; filename*=UTF-8''${extendedName}` : "" }`;} export function assetResponseHeaders( filePath: string, options?: { readonly download?: boolean; readonly fileName?: string; readonly mimeType?: string; },): Record<string, string> { const lowerPath = filePath.toLowerCase(); const inlineVideoMimeType = options?.mimeType?.split(";", 1)[0]?.trim(); return { "Cache-Control": "private, max-age=3600", "X-Content-Type-Options": "nosniff", ...(options?.download ? { "Content-Disposition": downloadContentDisposition(options.fileName), "Content-Security-Policy": "default-src 'none'; sandbox", "Content-Type": options.mimeType !== undefined && isSafeDownloadMimeType(options.mimeType) ? options.mimeType : "application/octet-stream", } : inlineVideoMimeType !== undefined && isSafeInlineVideoMimeType(inlineVideoMimeType) ? { "Content-Type": inlineVideoMimeType } : lowerPath.endsWith(".html") || lowerPath.endsWith(".htm") ? { "Content-Type": "text/html; charset=utf-8" } : {}), ...(!options?.download && lowerPath.endsWith(".svg") ? { "Content-Security-Policy": SVG_CONTENT_SECURITY_POLICY } : {}), };} export const httpCompressionLayer = HttpRouter.middleware(HttpMiddleware.compression(), { global: true,}); export const browserApiCorsLayer = Layer.unwrap( Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; const devOrigin = config.devUrl?.origin; // Dev uses credentialed requests from Vite or the Electron custom origin, so both must be // explicit. Packaged desktop omits credentials and uses Effect's default wildcard origin. // // T3CODE_DEV_ALLOWED_ORIGINS covers dev servers reached from a second // origin — a tailnet name, a LAN IP, a phone. Browser dev normally proxies // through Vite and is same-origin (no preflight at all), so this is a // safety net for the desktop renderer and any direct-to-backend caller. return HttpRouter.cors({ ...(devOrigin ? { allowedOrigins: [devOrigin, ...DESKTOP_RENDERER_ORIGINS, ...config.devAllowedOrigins], credentials: true, } : {}), allowedMethods: browserApiCorsAllowedMethods, allowedHeaders: browserApiCorsAllowedHeaders, 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 authenticateRawRouteWithScope = ( scope: typeof AuthOrchestrationReadScope | typeof AuthOrchestrationOperateScope,) => Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; const session = yield* serverAuth.authenticateHttpRequest(request).pipe( Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => failEnvironmentAuthInvalid( EnvironmentAuth.serverAuthCredentialReason(error), EnvironmentAuth.serverAuthDpopFailureReason(error), ), ), Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => failEnvironmentInternal("internal_error", error), ), ); if (!session.scopes.includes(scope)) { return yield* failEnvironmentScopeRequired(scope); } }); export const serverEnvironmentHttpApiLayer = HttpApiBuilder.group( EnvironmentHttpApi, "metadata", Effect.fnUntraced(function* (handlers) { const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; return handlers.handle( "descriptor", Effect.fn("environment.metadata.descriptor")(function* (args) { yield* annotateEnvironmentRequest(args.endpoint.name); return yield* serverEnvironment.getDescriptor; }, traceRelayRequest), ); }),); 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* authenticateRawRouteWithScope(AuthOrchestrationOperateScope); const request = yield* HttpServerRequest.HttpServerRequest; const config = yield* ServerConfig.ServerConfig; const otlpTracesUrl = config.otlpTracesUrl; const browserTraceCollector = yield* BrowserTraceCollector.BrowserTraceCollector; const httpClient = yield* HttpClient.HttpClient; const bodyJson = cast<unknown, OtlpTracer.TraceData>(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.orElseSucceed(() => HttpServerResponse.text("Trace export failed.", { status: 502 }), ), ); }).pipe( Effect.catchTags({ EnvironmentAuthInvalidError: HttpServerRespondable.toResponse, EnvironmentInternalError: HttpServerRespondable.toResponse, EnvironmentScopeRequiredError: HttpServerRespondable.toResponse, }), ),); export const assetRouteLayer = HttpRouter.add( "GET", `${ASSET_ROUTE_PREFIX}/*`, 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 suffix = url.value.pathname.slice(`${ASSET_ROUTE_PREFIX}/`.length); const separatorIndex = suffix.indexOf("/"); if (separatorIndex <= 0) { return HttpServerResponse.text("Not Found", { status: 404 }); } const asset = yield* resolveAsset( suffix.slice(0, separatorIndex), suffix.slice(separatorIndex + 1), ); if (!asset) { return HttpServerResponse.text("Not Found", { status: 404 }); } return yield* HttpServerResponse.file(asset.path, { status: 200, headers: assetResponseHeaders( asset.path, asset.download || asset.mimeType !== undefined ? { ...(asset.download ? { download: true } : {}), ...(asset.fileName !== undefined ? { fileName: asset.fileName } : {}), ...(asset.mimeType !== undefined ? { mimeType: asset.mimeType } : {}), } : undefined, ), }).pipe( Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })), ); }),); export const attachmentUploadRouteLayer = HttpRouter.add( "POST", `${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/*`, 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 token = url.value.pathname.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length); if (!token) { return HttpServerResponse.text("Not Found", { status: 404 }); } const claims = yield* validateAttachmentUploadToken(token); if (!claims) { return HttpServerResponse.text("Not Found", { status: 404 }); } const contentLengthHeader = request.headers["content-length"]; if ( contentLengthHeader !== undefined && (!Number.isInteger(Number(contentLengthHeader)) || Number(contentLengthHeader) !== claims.sizeBytes) ) { return HttpServerResponse.text("Content-Length must match the upload size.", { status: 400, }); } // Keep the request stream in the route scope until the response is sent. const bodyPull = yield* Stream.toPull(request.stream); const stored = yield* storeAttachmentUpload(claims, Stream.fromPull(Effect.succeed(bodyPull))); return stored.ok ? HttpServerResponse.empty({ status: 204 }) : HttpServerResponse.text(stored.detail, { status: stored.status }); }),); 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.ServerConfig; if (config.devUrl && isDevProxiedPath(url.value.pathname)) { return HttpServerResponse.text("Not Found", { status: 404 }); } if (config.devUrl && isLoopbackHostname(url.value.hostname)) { return HttpServerResponse.redirect(resolveDevRedirectUrl(config.devUrl, url.value), { status: 302, }); } const staticDir = config.staticDir ?? (config.devUrl ? yield* ServerConfig.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.orElseSucceed(() => null)); if (!fileInfo || fileInfo.type !== "File") { const indexPath = path.resolve(staticRoot, "index.html"); const indexData = yield* fileSystem .readFile(indexPath) .pipe(Effect.orElseSucceed(() => 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.orElseSucceed(() => null)); if (!data) { return HttpServerResponse.text("Internal Server Error", { status: 500 }); } return HttpServerResponse.uint8Array(data, { status: 200, contentType, }); }),);
import Mime from "@effect/platform-node/Mime";
import {
AuthOrchestrationOperateScope,
AuthOrchestrationReadScope,
EnvironmentHttpApi,
} from "@t3tools/contracts";
import { isDevProxiedPath } from "@t3tools/shared/devProxy";
import { decodeOtlpTraceRecords } from "@t3tools/shared/observability";
import * as Data from "effect/Data";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Stream from "effect/Stream";
import { cast } from "effect/Function";
import {
HttpBody,
HttpClient,
HttpClientResponse,
HttpMiddleware,
HttpRouter,
HttpServerResponse,
HttpServerRequest,
HttpServerRespondable,
} from "effect/unstable/http";
import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
import { OtlpTracer } from "effect/unstable/observability";
import * as ServerConfig from "./config.ts";
import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts";
import {
ATTACHMENT_UPLOAD_ROUTE_PREFIX,
storeAttachmentUpload,
validateAttachmentUploadToken,
} from "./assets/AttachmentUpload.ts";
import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts";
import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
import { traceRelayRequest } from "./cloud/traceRelayRequest.ts";
import {
annotateEnvironmentRequest,
failEnvironmentScopeRequired,
failEnvironmentAuthInvalid,
failEnvironmentInternal,
} from "./auth/http.ts";
import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./httpCors.ts";
const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces";
const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);
const DESKTOP_RENDERER_ORIGINS = ["t3code://app", "t3code-dev://app"];
const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
// Types a browser may render as a document if a proxy strips the disposition
// header. Downloads of these fall back to octet-stream.
const DOWNLOAD_MIME_TYPE_PATTERN = /^[\w!#$&^.+-]+\/[\w!#$&^.+-]+$/;
const isSafeDownloadMimeType = (mimeType: string): boolean =>
DOWNLOAD_MIME_TYPE_PATTERN.test(mimeType) &&
!/(?:^text\/html$|\/xml(?:$|-)|\+xml$)/i.test(mimeType.trim().toLowerCase());
const isSafeInlineVideoMimeType = (mimeType: string): boolean =>
DOWNLOAD_MIME_TYPE_PATTERN.test(mimeType) && mimeType.toLowerCase().startsWith("video/");
/** RFC 6266 disposition with an ASCII fallback name plus a UTF-8 `filename*`. */
export function downloadContentDisposition(fileName?: string): string {
if (fileName === undefined) {
return "attachment";
}
// toWellFormed: encodeURIComponent throws URIError on unpaired surrogates.
// eslint-disable-next-line no-control-regex -- Header filenames must strip ASCII controls.
const sanitized = fileName.toWellFormed().replace(/[\u0000-\u001f"\\]/g, "_");
const asciiFallback = sanitized.replace(/[^\u0020-\u007e]/g, "_");
const needsExtended = asciiFallback !== sanitized;
const extendedName = encodeURIComponent(sanitized).replace(
/['()*]/g,
(character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`,
);
return `attachment; filename="${asciiFallback}"${
needsExtended ? `; filename*=UTF-8''${extendedName}` : ""
}`;
}
export function assetResponseHeaders(
filePath: string,
options?: {
readonly download?: boolean;
readonly fileName?: string;
readonly mimeType?: string;
},
): Record<string, string> {
const lowerPath = filePath.toLowerCase();
const inlineVideoMimeType = options?.mimeType?.split(";", 1)[0]?.trim();
return {
"Cache-Control": "private, max-age=3600",
"X-Content-Type-Options": "nosniff",
...(options?.download
? {
"Content-Disposition": downloadContentDisposition(options.fileName),
"Content-Security-Policy": "default-src 'none'; sandbox",
"Content-Type":
options.mimeType !== undefined && isSafeDownloadMimeType(options.mimeType)
? options.mimeType
: "application/octet-stream",
}
: inlineVideoMimeType !== undefined && isSafeInlineVideoMimeType(inlineVideoMimeType)
? { "Content-Type": inlineVideoMimeType }
: lowerPath.endsWith(".html") || lowerPath.endsWith(".htm")
? { "Content-Type": "text/html; charset=utf-8" }
: {}),
...(!options?.download && lowerPath.endsWith(".svg")
? { "Content-Security-Policy": SVG_CONTENT_SECURITY_POLICY }
: {}),
};
}
export const httpCompressionLayer = HttpRouter.middleware(HttpMiddleware.compression(), {
global: true,
});
export const browserApiCorsLayer = Layer.unwrap(
Effect.gen(function* () {
const config = yield* ServerConfig.ServerConfig;
const devOrigin = config.devUrl?.origin;
// Dev uses credentialed requests from Vite or the Electron custom origin, so both must be
// explicit. Packaged desktop omits credentials and uses Effect's default wildcard origin.
//
// T3CODE_DEV_ALLOWED_ORIGINS covers dev servers reached from a second
// origin — a tailnet name, a LAN IP, a phone. Browser dev normally proxies
// through Vite and is same-origin (no preflight at all), so this is a
// safety net for the desktop renderer and any direct-to-backend caller.
return HttpRouter.cors({
...(devOrigin
? {
allowedOrigins: [devOrigin, ...DESKTOP_RENDERER_ORIGINS, ...config.devAllowedOrigins],
credentials: true,
}
: {}),
allowedMethods: browserApiCorsAllowedMethods,
allowedHeaders: browserApiCorsAllowedHeaders,
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 authenticateRawRouteWithScope = (
scope: typeof AuthOrchestrationReadScope | typeof AuthOrchestrationOperateScope,
) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest;
const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
const session = yield* serverAuth.authenticateHttpRequest(request).pipe(
Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) =>
failEnvironmentAuthInvalid(
EnvironmentAuth.serverAuthCredentialReason(error),
EnvironmentAuth.serverAuthDpopFailureReason(error),
),
),
Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
failEnvironmentInternal("internal_error", error),
),
);
if (!session.scopes.includes(scope)) {
return yield* failEnvironmentScopeRequired(scope);
}
});
export const serverEnvironmentHttpApiLayer = HttpApiBuilder.group(
EnvironmentHttpApi,
"metadata",
Effect.fnUntraced(function* (handlers) {
const serverEnvironment = yield* ServerEnvironment.ServerEnvironment;
return handlers.handle(
"descriptor",
Effect.fn("environment.metadata.descriptor")(function* (args) {
yield* annotateEnvironmentRequest(args.endpoint.name);
return yield* serverEnvironment.getDescriptor;
}, traceRelayRequest),
);
}),
);
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* authenticateRawRouteWithScope(AuthOrchestrationOperateScope);
const request = yield* HttpServerRequest.HttpServerRequest;
const config = yield* ServerConfig.ServerConfig;
const otlpTracesUrl = config.otlpTracesUrl;
const browserTraceCollector = yield* BrowserTraceCollector.BrowserTraceCollector;
const httpClient = yield* HttpClient.HttpClient;
const bodyJson = cast<unknown, OtlpTracer.TraceData>(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.orElseSucceed(() =>
HttpServerResponse.text("Trace export failed.", { status: 502 }),
),
);
}).pipe(
Effect.catchTags({
EnvironmentAuthInvalidError: HttpServerRespondable.toResponse,
EnvironmentInternalError: HttpServerRespondable.toResponse,
EnvironmentScopeRequiredError: HttpServerRespondable.toResponse,
}),
),
);
export const assetRouteLayer = HttpRouter.add(
"GET",
`${ASSET_ROUTE_PREFIX}/*`,
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 suffix = url.value.pathname.slice(`${ASSET_ROUTE_PREFIX}/`.length);
const separatorIndex = suffix.indexOf("/");
if (separatorIndex <= 0) {
return HttpServerResponse.text("Not Found", { status: 404 });
}
const asset = yield* resolveAsset(
suffix.slice(0, separatorIndex),
suffix.slice(separatorIndex + 1),
);
if (!asset) {
return HttpServerResponse.text("Not Found", { status: 404 });
}
return yield* HttpServerResponse.file(asset.path, {
status: 200,
headers: assetResponseHeaders(
asset.path,
asset.download || asset.mimeType !== undefined
? {
...(asset.download ? { download: true } : {}),
...(asset.fileName !== undefined ? { fileName: asset.fileName } : {}),
...(asset.mimeType !== undefined ? { mimeType: asset.mimeType } : {}),
}
: undefined,
),
}).pipe(
Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })),
);
}),
);
export const attachmentUploadRouteLayer = HttpRouter.add(
"POST",
`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/*`,
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 token = url.value.pathname.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length);
if (!token) {
return HttpServerResponse.text("Not Found", { status: 404 });
}
const claims = yield* validateAttachmentUploadToken(token);
if (!claims) {
return HttpServerResponse.text("Not Found", { status: 404 });
}
const contentLengthHeader = request.headers["content-length"];
if (
contentLengthHeader !== undefined &&
(!Number.isInteger(Number(contentLengthHeader)) ||
Number(contentLengthHeader) !== claims.sizeBytes)
) {
return HttpServerResponse.text("Content-Length must match the upload size.", {
status: 400,
});
}
// Keep the request stream in the route scope until the response is sent.
const bodyPull = yield* Stream.toPull(request.stream);
const stored = yield* storeAttachmentUpload(claims, Stream.fromPull(Effect.succeed(bodyPull)));
return stored.ok
? HttpServerResponse.empty({ status: 204 })
: HttpServerResponse.text(stored.detail, { status: stored.status });
}),
);
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.ServerConfig;
if (config.devUrl && isDevProxiedPath(url.value.pathname)) {
return HttpServerResponse.text("Not Found", { status: 404 });
}
if (config.devUrl && isLoopbackHostname(url.value.hostname)) {
return HttpServerResponse.redirect(resolveDevRedirectUrl(config.devUrl, url.value), {
status: 302,
});
}
const staticDir =
config.staticDir ?? (config.devUrl ? yield* ServerConfig.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.orElseSucceed(() => null));
if (!fileInfo || fileInfo.type !== "File") {
const indexPath = path.resolve(staticRoot, "index.html");
const indexData = yield* fileSystem
.readFile(indexPath)
.pipe(Effect.orElseSucceed(() => 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.orElseSucceed(() => null));
if (!data) {
return HttpServerResponse.text("Internal Server Error", { status: 500 });
}
return HttpServerResponse.uint8Array(data, {
status: 200,
contentType,
});
}),
);