1import * as Mime from "effect/http/Mime";
2import {
3 AuthOrchestrationOperateScope,
4 AuthArchiveReadScope,
5 AuthArchiveWriteScope,
6 AuthOrchestrationReadScope,
7 AuthProvidersManageScope,
8 ArchiveManagementRequest,
9 ArchiveError,
10 EnvironmentAuthInvalidError,
11 EnvironmentInternalError,
12 EnvironmentScopeRequiredError,
13 SharedAccountRequest,
14 SharedProfilesRequest,
15 SharedAccountError,
16 type AuthEnvironmentScope,
17 EnvironmentHttpApi,
18} from "@t3tools/contracts";
19import { isDevProxiedPath } from "@t3tools/shared/devProxy";
20import { injectMcpAppCookieBootstrap, injectMcpAppThemeBootstrap } from "@t3tools/shared/mcpApp";
21import { decodeOtlpTraceRecords } from "@t3tools/shared/observability";
22import * as Data from "effect/Data";
23import * as Effect from "effect/Effect";
24import * as Redacted from "effect/Redacted";
25import * as FileSystem from "effect/FileSystem";
26import * as Layer from "effect/Layer";
27import * as Option from "effect/Option";
28import * as Path from "effect/Path";
29import * as Schema from "effect/Schema";
30import * as Stream from "effect/Stream";
31import { cast } from "effect/Function";
32import {
33 HttpClient,
34 HttpClientResponse,
35 HttpMiddleware,
36 HttpRouter,
37 HttpServerResponse,
38 HttpServerRequest,
39 HttpServerRespondable,
40} from "effect/http";
41import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
42import { OtlpTracer, OtlpSerialization } from "effect/observability";
43
44import * as ServerConfig from "./config.ts";
45import * as ArchiveObjects from "./archive/ArchiveObjects.ts";
46import * as ArchiveAssetResponse from "./archive/ArchiveAssetResponse.ts";
47import * as TranscriptArchive from "./archive/TranscriptArchive.ts";
48import { ASSET_ROUTE_PREFIX, readMcpAppHtml, resolveAsset } from "./assets/AssetAccess.ts";
49import { githubMediaResponse } from "./assets/GitHubMediaFetch.ts";
50import { statMediaFile, streamMediaFile, type OpenMediaFile } from "./assets/MediaFile.ts";
51import {
52 ATTACHMENT_UPLOAD_ROUTE_PREFIX,
53 storeAttachmentUpload,
54 validateAttachmentUploadToken,
55} from "./assets/AttachmentUpload.ts";
56import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts";
57import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
58import * as SharedAccounts from "./provider/SharedAccounts.ts";
59import * as SharedProfiles from "./provider/SharedProfiles.ts";
60import {
61 annotateEnvironmentRequest,
62 failEnvironmentScopeRequired,
63 failEnvironmentAuthInvalid,
64 failEnvironmentInternal,
65} from "./auth/http.ts";
66import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
67import { WEBHOOK_ROUTE_PREFIX } from "./scheduledTasks/ScheduledTaskService.ts";
68import {
69 browserApiCorsAllowedHeaders,
70 browserApiCorsAllowedMethods,
71 DESKTOP_RENDERER_ORIGINS,
72} from "./httpCors.ts";
73
74const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces";
75const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);
76const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
77// HTML previews are agent output, not the app. The sandbox gives the document an
78// opaque origin: scripts run, but same-origin cookies, storage, and API calls are
79// out of reach. Relative sibling assets still load through their signed URLs.
80// No modals: agent HTML can open without a click (inline renders, and mobile
81// loads it as the top document), and must not raise blocking dialogs. Downloads
82// stay allowed so download links and buttons in the page work.
83const HTML_CONTENT_SECURITY_POLICY =
84 "sandbox allow-scripts allow-forms allow-popups allow-downloads";
85
86// Types a browser may render as a document if a proxy strips the disposition
87// header. Downloads of these fall back to octet-stream.
88const DOWNLOAD_MIME_TYPE_PATTERN = /^[\w!#$&^.+-]+\/[\w!#$&^.+-]+$/;
89const isSafeDownloadMimeType = (mimeType: string): boolean =>
90 DOWNLOAD_MIME_TYPE_PATTERN.test(mimeType) &&
91 !/(?:^text\/html$|\/xml(?:$|-)|\+xml$)/i.test(mimeType.trim().toLowerCase());
92const isSafeInlineMediaMimeType = (mimeType: string): boolean =>
93 DOWNLOAD_MIME_TYPE_PATTERN.test(mimeType) && /^(?:audio|video)\//i.test(mimeType);
94const isSafeInlineDocumentMimeType = (mimeType: string): boolean =>
95 mimeType.toLowerCase() === "application/pdf" || mimeType.toLowerCase() === "text/html";
96
97/** RFC 6266 disposition with an ASCII fallback name plus a UTF-8 `filename*`. */
98export function downloadContentDisposition(fileName?: string): string {
99 if (fileName === undefined) {
100 return "attachment";
101 }
102 // toWellFormed: encodeURIComponent throws URIError on unpaired surrogates.
103 const sanitized = fileName.toWellFormed().replace(/[\p{Cc}"\\]/gu, "_");
104 const asciiFallback = sanitized.replace(/[^\u0020-\u007e]/g, "_");
105 const needsExtended = asciiFallback !== sanitized;
106 const extendedName = encodeURIComponent(sanitized).replace(
107 /['()*]/g,
108 (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`,
109 );
110 return `attachment; filename="${asciiFallback}"${
111 needsExtended ? `; filename*=UTF-8''${extendedName}` : ""
112 }`;
113}
114
115export function assetResponseHeaders(
116 filePath: string,
117 options?: {
118 readonly download?: boolean;
119 readonly fileName?: string;
120 readonly mimeType?: string;
121 },
122): Record<string, string> {
123 const lowerPath = filePath.toLowerCase();
124 const inlineMimeType = options?.mimeType?.split(";", 1)[0]?.trim();
125 return {
126 "Cache-Control": "private, max-age=3600",
127 "X-Content-Type-Options": "nosniff",
128 ...(options?.download
129 ? {
130 "Content-Disposition": downloadContentDisposition(options.fileName),
131 "Content-Security-Policy": "default-src 'none'; sandbox",
132 "Content-Type":
133 options.mimeType !== undefined && isSafeDownloadMimeType(options.mimeType)
134 ? options.mimeType
135 : "application/octet-stream",
136 }
137 : inlineMimeType !== undefined && isSafeInlineMediaMimeType(inlineMimeType)
138 ? { "Content-Type": inlineMimeType }
139 : inlineMimeType !== undefined && isSafeInlineDocumentMimeType(inlineMimeType)
140 ? {
141 "Content-Type":
142 inlineMimeType.toLowerCase() === "text/html"
143 ? "text/html; charset=utf-8"
144 : "application/pdf",
145 ...(inlineMimeType.toLowerCase() === "text/html"
146 ? { "Content-Security-Policy": HTML_CONTENT_SECURITY_POLICY }
147 : {}),
148 }
149 : lowerPath.endsWith(".html") || lowerPath.endsWith(".htm")
150 ? {
151 "Content-Type": "text/html; charset=utf-8",
152 "Content-Security-Policy": HTML_CONTENT_SECURITY_POLICY,
153 }
154 : {}),
155 ...(!options?.download && lowerPath.endsWith(".svg")
156 ? { "Content-Security-Policy": SVG_CONTENT_SECURITY_POLICY }
157 : {}),
158 };
159}
160
161/** A single byte range for native media readers; unsupported range syntax uses the full file. */
162function assetByteRange(header: string, size: bigint) {
163 const match = /^bytes=(\d*)-(\d*)$/i.exec(header.trim());
164 if (!match || (!match[1] && !match[2])) return null;
165 const first = match[1] ? BigInt(match[1]) : null;
166 const last = match[2] ? BigInt(match[2]) : null;
167 if (first !== null && last !== null && last < first) return null;
168 if (size === 0n || (first !== null && first >= size) || (first === null && last === 0n)) {
169 return { _tag: "Unsatisfiable" as const };
170 }
171 const start = first ?? (last! >= size ? 0n : size - last!);
172 const end = first === null || last === null || last >= size ? size - 1n : last;
173 if (!Number.isSafeInteger(Number(start)) || !Number.isSafeInteger(Number(end))) {
174 return { _tag: "Unsatisfiable" as const };
175 }
176 return {
177 _tag: "Range" as const,
178 offset: start,
179 bytesToRead: end - start + 1n,
180 contentRange: `bytes ${start}-${end}/${size}`,
181 };
182}
183
184export const assetFileResponse = Effect.fn("assetFileResponse")(function* (
185 asset: {
186 readonly path: string;
187 readonly download?: boolean;
188 readonly fileName?: string;
189 readonly mimeType?: string;
190 readonly file?: OpenMediaFile;
191 readonly renderIntent?: "mcp-app";
192 },
193 rangeHeader?: string,
194 ifRangeHeader?: string,
195 method: "GET" | "HEAD" = "GET",
196) {
197 const headers = assetResponseHeaders(asset.path, asset);
198 if (
199 asset.renderIntent === "mcp-app" &&
200 !asset.download &&
201 headers["Content-Type"] === "text/html; charset=utf-8"
202 ) {
203 const bytes = yield* readMcpAppHtml(asset.path);
204 if (bytes === null) {
205 return HttpServerResponse.text("MCP app is too large to preview.", { status: 413 });
206 }
207 const body = new TextEncoder().encode(
208 injectMcpAppCookieBootstrap(injectMcpAppThemeBootstrap(new TextDecoder().decode(bytes))),
209 );
210 headers["Content-Length"] = String(body.byteLength);
211 return method === "HEAD"
212 ? HttpServerResponse.empty({ status: 200, headers })
213 : HttpServerResponse.uint8Array(body, { headers });
214 }
215 const mediaFile = asset.file;
216 const mediaInfo = mediaFile ? yield* statMediaFile(asset.path, mediaFile) : undefined;
217 const isMedia = /^(?:audio|video)\//i.test(headers["Content-Type"] ?? "");
218 if (isMedia) {
219 // Host media can change in place. Do not invite conditional range requests
220 // with validators that cannot establish byte-for-byte identity. Attachment media
221 // carries no `file`, and must not outlive the signed URL that granted it either.
222 headers["Cache-Control"] = "private, no-store";
223 }
224 let status = 200;
225 let offset = 0n;
226 let bytesToRead: bigint | undefined;
227 if (isMedia) {
228 headers["Accept-Ranges"] = "bytes";
229 // If-Range requires a matching validator. A full response is safe when we cannot validate it.
230 if (method === "GET" && rangeHeader && ifRangeHeader === undefined) {
231 const fs = yield* FileSystem.FileSystem;
232 const info = mediaInfo ?? (yield* fs.stat(asset.path));
233 const range = assetByteRange(rangeHeader, info.size);
234 if (range?._tag === "Unsatisfiable") {
235 return HttpServerResponse.empty({
236 status: 416,
237 headers: { ...headers, "Content-Range": `bytes */${info.size}` },
238 });
239 }
240 if (range?._tag === "Range") {
241 status = 206;
242 offset = range.offset;
243 bytesToRead = range.bytesToRead;
244 headers["Content-Range"] = range.contentRange;
245 }
246 }
247 }
248 if (mediaFile && mediaInfo) {
249 const size = bytesToRead ?? mediaInfo.size;
250 headers["Content-Type"] ??= Option.getOrElse(
251 Mime.getType(asset.path),
252 () => "application/octet-stream",
253 );
254 headers["Content-Length"] = String(size);
255 if (!isMedia) {
256 headers["Last-Modified"] = mediaInfo.mtime.toUTCString();
257 headers.ETag = `W/"${mediaInfo.size.toString(16)}-${mediaInfo.mtimeMs.toString(16)}"`;
258 }
259 if (method === "HEAD" || size === 0n) {
260 return HttpServerResponse.empty({ status, headers });
261 }
262 const body = streamMediaFile(mediaFile, offset, size);
263 if (!body) {
264 return HttpServerResponse.text("File is too large to preview.", { status: 413 });
265 }
266 return HttpServerResponse.stream(body, {
267 status,
268 headers,
269 });
270 }
271 return yield* HttpServerResponse.file(asset.path, { status, offset, bytesToRead, headers });
272});
273
274export const layerHttpCompression = HttpRouter.middleware(HttpMiddleware.compression(), {
275 global: true,
276});
277
278export const layerBrowserApiCors = Layer.unwrap(
279 Effect.gen(function* () {
280 const config = yield* ServerConfig.ServerConfig;
281 const devOrigin = config.devUrl?.origin;
282 // Dev uses credentialed requests from Vite or the Electron custom origin, so both must be
283 // explicit. Packaged desktop omits credentials and uses Effect's default wildcard origin.
284 //
285 // T3CODE_DEV_ALLOWED_ORIGINS covers dev servers reached from a second
286 // origin — a tailnet name, a LAN IP, a phone. Browser dev normally proxies
287 // through Vite and is same-origin (no preflight at all), so this is a
288 // safety net for the desktop renderer and any direct-to-backend caller.
289 return HttpRouter.cors({
290 ...(devOrigin
291 ? {
292 allowedOrigins: [devOrigin, ...DESKTOP_RENDERER_ORIGINS, ...config.devAllowedOrigins],
293 credentials: true,
294 }
295 : {}),
296 allowedMethods: browserApiCorsAllowedMethods,
297 allowedHeaders: browserApiCorsAllowedHeaders,
298 maxAge: 600,
299 });
300 }),
301);
302
303export function isLoopbackHostname(hostname: string): boolean {
304 const normalizedHostname = hostname
305 .trim()
306 .toLowerCase()
307 .replace(/^\[(.*)\]$/, "$1");
308 return LOOPBACK_HOSTNAMES.has(normalizedHostname);
309}
310
311export function resolveDevRedirectUrl(devUrl: URL, requestUrl: URL): string {
312 const redirectUrl = new URL(devUrl.toString());
313 redirectUrl.pathname = requestUrl.pathname;
314 redirectUrl.search = requestUrl.search;
315 redirectUrl.hash = requestUrl.hash;
316 return redirectUrl.toString();
317}
318
319export const authenticateRawRouteWithScope = (
320 scope: AuthEnvironmentScope,
321 legacyScope?: AuthEnvironmentScope,
322) =>
323 Effect.gen(function* () {
324 const request = yield* HttpServerRequest.HttpServerRequest;
325 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
326 const session = yield* serverAuth.authenticateHttpRequest(request).pipe(
327 Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) =>
328 failEnvironmentAuthInvalid(
329 EnvironmentAuth.serverAuthCredentialReason(error),
330 EnvironmentAuth.serverAuthDpopFailureReason(error),
331 ),
332 ),
333 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
334 failEnvironmentInternal("internal_error", error),
335 ),
336 );
337 if (!session.scopes.includes(scope) && !(legacyScope && session.scopes.includes(legacyScope))) {
338 return yield* failEnvironmentScopeRequired(scope);
339 }
340 return session;
341 });
342
343const isArchiveError = Schema.is(ArchiveError);
344const isArchiveAuthError = Schema.is(
345 Schema.Union([
346 EnvironmentAuthInvalidError,
347 EnvironmentInternalError,
348 EnvironmentScopeRequiredError,
349 ]),
350);
351export const archiveRouteErrors = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
352 effect.pipe(
353 Effect.catch((error): Effect.Effect<HttpServerResponse.HttpServerResponse, E> => {
354 if (isArchiveError(error))
355 return Effect.succeed(
356 HttpServerResponse.jsonUnsafe(
357 { _tag: error._tag, reason: error.reason },
358 {
359 status: error.reason === "integrity" ? 400 : 503,
360 headers: { "cache-control": "no-store" },
361 },
362 ),
363 );
364 if (isArchiveAuthError(error)) return HttpServerRespondable.toResponse(error);
365 return Effect.fail(error);
366 }),
367 );
368
369export const layerArchiveManagementRoute = HttpRouter.add(
370 "POST",
371 "/api/archive/manage",
372 Effect.gen(function* () {
373 const input = yield* HttpServerRequest.schemaBodyJson(ArchiveManagementRequest).pipe(
374 Effect.mapError(
375 (cause) => new ArchiveError({ reason: "integrity", cause: Redacted.make(cause) }),
376 ),
377 );
378 yield* authenticateRawRouteWithScope(
379 input.type === "archive.status" ? AuthArchiveReadScope : AuthArchiveWriteScope,
380 input.type === "archive.status" ? AuthOrchestrationReadScope : AuthOrchestrationOperateScope,
381 );
382 const archive = yield* TranscriptArchive.TranscriptArchive;
383 return yield* archive
384 .manage(input)
385 .pipe(
386 Effect.flatMap((status) =>
387 HttpServerResponse.json(status, { headers: { "cache-control": "no-store" } }),
388 ),
389 );
390 }).pipe(archiveRouteErrors),
391);
392
393export const layerArchiveUploadRoute = HttpRouter.add(
394 "PUT",
395 "/api/archive/objects/:hash",
396 Effect.gen(function* () {
397 yield* authenticateRawRouteWithScope(AuthArchiveWriteScope, AuthOrchestrationOperateScope);
398 const request = yield* HttpServerRequest.HttpServerRequest;
399 const params = yield* HttpRouter.params;
400 const objects = yield* ArchiveObjects.ArchiveObjects;
401 return yield* objects
402 .put(params.hash ?? "", request.stream)
403 .pipe(
404 Effect.flatMap((receipt) =>
405 HttpServerResponse.json(receipt, { headers: { "cache-control": "no-store" } }),
406 ),
407 );
408 }).pipe(archiveRouteErrors, Effect.withTracerEnabled(false)),
409);
410
411export const layerArchiveDownloadRoute = HttpRouter.add(
412 "GET",
413 "/api/archive/objects/:hash",
414 Effect.gen(function* () {
415 yield* authenticateRawRouteWithScope(AuthArchiveReadScope, AuthOrchestrationReadScope);
416 const request = yield* HttpServerRequest.HttpServerRequest;
417 const params = yield* HttpRouter.params;
418 const objects = yield* ArchiveObjects.ArchiveObjects;
419 const fs = yield* FileSystem.FileSystem;
420 return yield* Effect.gen(function* () {
421 const file = yield* objects.file(params.hash ?? "");
422 const stat = yield* fs.stat(file);
423 const range =
424 request.headers.range && !request.headers["if-range"]
425 ? assetByteRange(request.headers.range, stat.size)
426 : null;
427 if (range?._tag === "Unsatisfiable")
428 return HttpServerResponse.empty({
429 status: 416,
430 headers: { "content-range": `bytes */${stat.size}` },
431 });
432 const bounded = range?._tag === "Range" ? range : undefined;
433 return HttpServerResponse.stream(
434 fs.stream(file, {
435 offset: bounded?.offset ?? 0n,
436 ...(bounded ? { bytesToRead: bounded.bytesToRead } : {}),
437 }),
438 {
439 status: bounded ? 206 : 200,
440 headers: {
441 "content-type": "application/octet-stream",
442 "content-length": String(bounded?.bytesToRead ?? stat.size),
443 "cache-control": "private, no-store",
444 "accept-ranges": "bytes",
445 ...(bounded ? { "content-range": bounded.contentRange } : {}),
446 },
447 },
448 );
449 }).pipe(
450 Effect.orElseSucceed(() =>
451 HttpServerResponse.empty({ status: 503, headers: { "cache-control": "no-store" } }),
452 ),
453 );
454 }).pipe(archiveRouteErrors, Effect.withTracerEnabled(false)),
455);
456
457export const layerSharedAccountRoute = HttpRouter.add(
458 "POST",
459 "/api/shared/accounts",
460 Effect.gen(function* () {
461 yield* authenticateRawRouteWithScope(AuthProvidersManageScope);
462 const request = yield* HttpServerRequest.HttpServerRequest;
463 const accounts = yield* SharedAccounts.SharedAccounts;
464 const input = yield* request.json.pipe(
465 Effect.flatMap(Schema.decodeUnknownEffect(SharedAccountRequest)),
466 Effect.mapError(
467 (cause) => new SharedAccountError({ reason: "connection", cause: Redacted.make(cause) }),
468 ),
469 );
470 return HttpServerResponse.jsonUnsafe(yield* accounts.execute(input), {
471 headers: { "cache-control": "no-store" },
472 });
473 }).pipe(
474 Effect.catchTags({
475 SharedAccountError: (error) =>
476 Effect.succeed(
477 HttpServerResponse.jsonUnsafe(
478 { _tag: error._tag, reason: error.reason },
479 {
480 status: error.reason === "missing" ? 404 : error.reason === "stale" ? 409 : 503,
481 headers: { "cache-control": "no-store" },
482 },
483 ),
484 ),
485 EnvironmentAuthInvalidError: HttpServerRespondable.toResponse,
486 EnvironmentInternalError: HttpServerRespondable.toResponse,
487 EnvironmentScopeRequiredError: HttpServerRespondable.toResponse,
488 }),
489 Effect.withTracerEnabled(false),
490 ),
491);
492
493export const layerSharedProfilesRoute = HttpRouter.add(
494 "POST",
495 "/api/shared/profiles",
496 Effect.gen(function* () {
497 yield* authenticateRawRouteWithScope(AuthProvidersManageScope);
498 const request = yield* HttpServerRequest.HttpServerRequest;
499 const profiles = yield* SharedProfiles.SharedProfiles;
500 const input = yield* request.json.pipe(
501 Effect.flatMap(Schema.decodeUnknownEffect(SharedProfilesRequest)),
502 Effect.mapError(
503 (cause) => new SharedAccountError({ reason: "setup", cause: Redacted.make(cause) }),
504 ),
505 );
506 return HttpServerResponse.jsonUnsafe(yield* profiles.execute(input), {
507 headers: { "cache-control": "no-store" },
508 });
509 }).pipe(
510 Effect.catchTags({
511 SharedAccountError: (error) =>
512 Effect.succeed(
513 HttpServerResponse.jsonUnsafe(
514 { _tag: error._tag, reason: error.reason },
515 { status: 503, headers: { "cache-control": "no-store" } },
516 ),
517 ),
518 EnvironmentAuthInvalidError: HttpServerRespondable.toResponse,
519 EnvironmentInternalError: HttpServerRespondable.toResponse,
520 EnvironmentScopeRequiredError: HttpServerRespondable.toResponse,
521 }),
522 Effect.withTracerEnabled(false),
523 ),
524);
525
526export const layerServerEnvironmentHttpApi = HttpApiBuilder.group(
527 EnvironmentHttpApi,
528 "metadata",
529 Effect.fnUntraced(function* (handlers) {
530 const serverEnvironment = yield* ServerEnvironment.ServerEnvironment;
531 return handlers.handle(
532 "descriptor",
533 Effect.fn("environment.metadata.descriptor")(function* (args) {
534 yield* annotateEnvironmentRequest(args.endpoint.name);
535 return yield* serverEnvironment.getDescriptor;
536 }),
537 );
538 }),
539);
540
541class DecodeOtlpTraceRecordsError extends Data.TaggedError("DecodeOtlpTraceRecordsError")<{
542 readonly cause: unknown;
543}> {}
544
545// Renderers export up to once a second while they have spans buffered, so
546// tracing this proxy would add more server spans than it forwards.
547// withTracerEnabled(false) drops the handler's spans, including the forward.
548// untracedRequestsLayer drops the HTTP server span.
549export const layerOtlpTracesProxyRoute = HttpRouter.add(
550 "POST",
551 OTLP_TRACES_PROXY_PATH,
552 Effect.gen(function* () {
553 yield* authenticateRawRouteWithScope(AuthOrchestrationOperateScope);
554 const request = yield* HttpServerRequest.HttpServerRequest;
555 const config = yield* ServerConfig.ServerConfig;
556 const otlpTracesUrl = config.otlpTracesUrl;
557 const otlpHeaders = config.otlpTracesExport.headers;
558 const browserTraceCollector = yield* BrowserTraceCollector.BrowserTraceCollector;
559 const httpClient = yield* HttpClient.HttpClient;
560 const serialization = yield* OtlpSerialization.OtlpSerialization;
561 const bodyJson = cast<unknown, OtlpTracer.TraceData>(yield* request.json);
562
563 yield* Effect.try({
564 try: () => decodeOtlpTraceRecords(bodyJson),
565 catch: (cause) => new DecodeOtlpTraceRecordsError({ cause }),
566 }).pipe(
567 Effect.flatMap((records) => browserTraceCollector.record(records)),
568 Effect.catch((cause) => Effect.logWarning("Failed to decode browser OTLP traces", { cause })),
569 );
570
571 if (otlpTracesUrl === undefined) {
572 return HttpServerResponse.empty({ status: 204 });
573 }
574
575 return yield* httpClient
576 .post(otlpTracesUrl, {
577 body: serialization.traces(bodyJson),
578 headers: otlpHeaders,
579 })
580 .pipe(
581 Effect.flatMap(HttpClientResponse.filterStatusOk),
582 Effect.as(HttpServerResponse.empty({ status: 204 })),
583 Effect.tapError((cause) =>
584 Effect.logWarning("Failed to export browser OTLP traces", {
585 cause,
586 otlpTracesUrl,
587 }),
588 ),
589 Effect.orElseSucceed(() =>
590 HttpServerResponse.text("Trace export failed.", { status: 502 }),
591 ),
592 );
593 }).pipe(
594 Effect.catchTags({
595 EnvironmentAuthInvalidError: HttpServerRespondable.toResponse,
596 EnvironmentInternalError: HttpServerRespondable.toResponse,
597 EnvironmentScopeRequiredError: HttpServerRespondable.toResponse,
598 }),
599 Effect.withTracerEnabled(false),
600 ),
601);
602
603const UNTRACED_REQUEST_PATHS: ReadonlySet<string> = new Set([OTLP_TRACES_PROXY_PATH]);
604
605// Skips the HTTP server span for UNTRACED_REQUEST_PATHS. That span starts
606// before routing, so a route handler cannot skip it. TracerDisabledWhen is one
607// predicate for the whole server, and HttpRouter.serve builds its routes
608// privately, so it is provided to the served layer, never merged into the
609// routes. Add paths here instead of providing it again. The query string is
610// ignored, as in routing.
611const layerUntracedRequests = Layer.succeed(HttpMiddleware.TracerDisabledWhen)((request) => {
612 const queryIndex = request.url.indexOf("?");
613 const path = queryIndex === -1 ? request.url : request.url.slice(0, queryIndex);
614 // Webhook URLs carry their secret token in the path, so they never reach a trace.
615 return UNTRACED_REQUEST_PATHS.has(path) || path.startsWith(`${WEBHOOK_ROUTE_PREFIX}/`);
616});
617
618export const withUntracedRequests = Layer.provide(layerUntracedRequests);
619
620export const layerAssetRoute = HttpRouter.add(
621 "GET",
622 `${ASSET_ROUTE_PREFIX}/*`,
623 Effect.gen(function* () {
624 const request = yield* HttpServerRequest.HttpServerRequest;
625 const url = HttpServerRequest.toURL(request);
626 if (Option.isNone(url)) {
627 return HttpServerResponse.text("Bad Request", { status: 400 });
628 }
629
630 const suffix = url.value.pathname.slice(`${ASSET_ROUTE_PREFIX}/`.length);
631 const separatorIndex = suffix.indexOf("/");
632 if (separatorIndex <= 0) {
633 return HttpServerResponse.text("Not Found", { status: 404 });
634 }
635
636 const asset = yield* resolveAsset(
637 suffix.slice(0, separatorIndex),
638 suffix.slice(separatorIndex + 1),
639 );
640 if (!asset) {
641 return HttpServerResponse.text("Not Found", { status: 404 });
642 }
643 if (asset.kind === "bytes") {
644 return HttpServerResponse.uint8Array(asset.bytes, {
645 contentType: asset.mimeType,
646 headers: { "cache-control": "private, max-age=3600", "x-content-type-options": "nosniff" },
647 });
648 }
649 if (asset.kind === "archived-file") {
650 return yield* ArchiveAssetResponse.remoteResponse(
651 asset,
652 request.headers["if-range"] ? undefined : request.headers.range,
653 ).pipe(
654 Effect.flatMap((result) =>
655 result.kind === "response"
656 ? Effect.succeed(result.response)
657 : assetFileResponse(
658 { ...asset, path: result.path },
659 request.headers.range,
660 request.headers["if-range"],
661 ),
662 ),
663 Effect.orElseSucceed(() =>
664 HttpServerResponse.empty({ status: 503, headers: { "cache-control": "no-store" } }),
665 ),
666 );
667 }
668 if (asset.kind === "github-media") {
669 return yield* githubMediaResponse(asset, request.headers).pipe(
670 Effect.tapError((cause) =>
671 Effect.logWarning("Failed to fetch GitHub media.", { url: asset.url, cause }),
672 ),
673 Effect.orElseSucceed(() =>
674 HttpServerResponse.empty({
675 status: 502,
676 headers: { "cache-control": "private, no-store", "x-content-type-options": "nosniff" },
677 }),
678 ),
679 );
680 }
681 return yield* assetFileResponse(
682 asset,
683 request.method === "GET" ? request.headers.range : undefined,
684 request.headers["if-range"],
685 request.method === "HEAD" ? "HEAD" : "GET",
686 ).pipe(
687 Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })),
688 );
689 }),
690);
691
692export const layerAttachmentUploadRoute = HttpRouter.add(
693 "POST",
694 `${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/*`,
695 Effect.gen(function* () {
696 const request = yield* HttpServerRequest.HttpServerRequest;
697 const url = HttpServerRequest.toURL(request);
698 if (Option.isNone(url)) {
699 return HttpServerResponse.text("Bad Request", { status: 400 });
700 }
701
702 const token = url.value.pathname.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length);
703 if (!token) {
704 return HttpServerResponse.text("Not Found", { status: 404 });
705 }
706 const claims = yield* validateAttachmentUploadToken(token);
707 if (!claims) {
708 return HttpServerResponse.text("Not Found", { status: 404 });
709 }
710
711 const contentLengthHeader = request.headers["content-length"];
712 if (
713 contentLengthHeader !== undefined &&
714 (!Number.isInteger(Number(contentLengthHeader)) ||
715 Number(contentLengthHeader) !== claims.sizeBytes)
716 ) {
717 return HttpServerResponse.text("Content-Length must match the upload size.", {
718 status: 400,
719 });
720 }
721
722 // Keep the request stream in the route scope until the response is sent.
723 const bodyPull = yield* Stream.toPull(request.stream);
724 const stored = yield* storeAttachmentUpload(claims, Stream.fromPull(Effect.succeed(bodyPull)));
725 return stored.ok
726 ? HttpServerResponse.empty({ status: 204 })
727 : HttpServerResponse.text(stored.detail, { status: stored.status });
728 }),
729);
730
731const decodeBuildManifest = Schema.decodeUnknownEffect(
732 Schema.fromJsonString(
733 Schema.Record(
734 Schema.String,
735 Schema.Struct({
736 file: Schema.String,
737 css: Schema.optional(Schema.Array(Schema.String)),
738 assets: Schema.optional(Schema.Array(Schema.String)),
739 }),
740 ),
741 ),
742);
743
744const loadImmutableBuildAssets = Effect.gen(function* () {
745 const config = yield* ServerConfig.ServerConfig;
746 const staticDir =
747 config.staticDir ?? (config.devUrl ? yield* ServerConfig.resolveStaticDir() : undefined);
748 if (!staticDir) return new Set<string>();
749 const fileSystem = yield* FileSystem.FileSystem;
750 const path = yield* Path.Path;
751 return yield* fileSystem.readFileString(path.join(staticDir, ".vite", "manifest.json")).pipe(
752 Effect.flatMap(decodeBuildManifest),
753 Effect.map(
754 (manifest) =>
755 new Set(
756 Object.values(manifest).flatMap((entry) => [
757 entry.file,
758 ...(entry.css ?? []),
759 ...(entry.assets ?? []),
760 ]),
761 ),
762 ),
763 Effect.orElseSucceed(() => new Set<string>()),
764 );
765});
766
767const openStaticFile = Effect.fn("openStaticFile")(function* (filePath: string) {
768 const fileSystem = yield* FileSystem.FileSystem;
769 // Reject directories and special files before opening. Response metadata comes from the handle.
770 const pathInfo = yield* fileSystem.stat(filePath).pipe(Effect.orElseSucceed(() => null));
771 if (pathInfo?.type !== "File") return null;
772 const file = yield* fileSystem.open(filePath, { flag: "r" });
773 const info = yield* file.stat;
774 return info.type === "File" ? { file, info } : null;
775});
776
777const streamStaticFile = (file: FileSystem.File, size: bigint) =>
778 Stream.unfold(
779 0n,
780 Effect.fnUntraced(function* (offset: bigint) {
781 if (offset >= size) return;
782 const remaining = size - offset;
783 const bytes = yield* file.readAlloc(Number(remaining < 65_536n ? remaining : 65_536n));
784 if (Option.isNone(bytes)) return;
785 return [bytes.value, offset + BigInt(bytes.value.byteLength)] as const;
786 }),
787 );
788
789const handleStaticAndDevRequest = Effect.fn("handleStaticAndDevRequest")(
790 function* (immutableBuildAssets: ReadonlySet<string>) {
791 const request = yield* HttpServerRequest.HttpServerRequest;
792 const url = HttpServerRequest.toURL(request);
793
794 if (Option.isNone(url)) {
795 return HttpServerResponse.text("Bad Request", { status: 400 });
796 }
797
798 const config = yield* ServerConfig.ServerConfig;
799 if (config.devUrl && isDevProxiedPath(url.value.pathname)) {
800 return HttpServerResponse.text("Not Found", { status: 404 });
801 }
802
803 if (config.devUrl && isLoopbackHostname(url.value.hostname)) {
804 return HttpServerResponse.redirect(resolveDevRedirectUrl(config.devUrl, url.value), {
805 status: 302,
806 });
807 }
808
809 const staticDir =
810 config.staticDir ?? (config.devUrl ? yield* ServerConfig.resolveStaticDir() : undefined);
811 if (!staticDir) {
812 return HttpServerResponse.text("No static directory configured and no dev URL set.", {
813 status: 503,
814 });
815 }
816
817 const path = yield* Path.Path;
818 const staticRoot = path.resolve(staticDir);
819 const staticRequestPath = url.value.pathname === "/" ? "/index.html" : url.value.pathname;
820 const rawStaticRelativePath = staticRequestPath.replace(/^[/\\]+/, "");
821 const hasRawLeadingParentSegment = rawStaticRelativePath.startsWith("..");
822 const staticRelativePath = path.normalize(rawStaticRelativePath).replace(/^[/\\]+/, "");
823 const hasPathTraversalSegment = staticRelativePath.startsWith("..");
824 if (
825 staticRelativePath.length === 0 ||
826 hasRawLeadingParentSegment ||
827 hasPathTraversalSegment ||
828 staticRelativePath.includes("\0")
829 ) {
830 return HttpServerResponse.text("Invalid static file path", { status: 400 });
831 }
832
833 const isWithinStaticRoot = (candidate: string) =>
834 candidate === staticRoot ||
835 candidate.startsWith(staticRoot.endsWith(path.sep) ? staticRoot : `${staticRoot}${path.sep}`);
836
837 let filePath = path.resolve(staticRoot, staticRelativePath);
838 if (!isWithinStaticRoot(filePath)) {
839 return HttpServerResponse.text("Invalid static file path", { status: 400 });
840 }
841
842 const ext = path.extname(filePath);
843 if (!ext) {
844 filePath = path.resolve(filePath, "index.html");
845 if (!isWithinStaticRoot(filePath)) {
846 return HttpServerResponse.text("Invalid static file path", { status: 400 });
847 }
848 }
849
850 let opened = yield* openStaticFile(filePath);
851 if (!opened) {
852 filePath = path.resolve(staticRoot, "index.html");
853 opened = yield* openStaticFile(filePath);
854 if (!opened) {
855 return HttpServerResponse.text("Not Found", { status: 404 });
856 }
857 }
858 const fileInfo = opened.info;
859 const mimeType = Option.getOrElse(Mime.getType(filePath), () => "application/octet-stream");
860 const isHtml = mimeType === "text/html";
861
862 // A hash-like name is not enough: custom static files can use the same naming pattern.
863 const relativePath = path.relative(staticRoot, filePath).replaceAll("\\", "/");
864 const immutable =
865 !isHtml &&
866 /^assets\/.+-[\w-]{8}\.[^/]+$/.test(relativePath) &&
867 immutableBuildAssets.has(relativePath);
868 const headers: Record<string, string> = {
869 "Cache-Control": immutable ? "public, max-age=31536000, immutable" : "no-cache",
870 };
871 // Deployments can preserve HTML size and mtime while changing its bundle URLs.
872 const modifiedAt = isHtml ? undefined : Option.getOrUndefined(fileInfo.mtime);
873 const etag = modifiedAt
874 ? `W/"${fileInfo.size.toString(16)}-${modifiedAt.getTime().toString(16)}"`
875 : undefined;
876 if (etag !== undefined && modifiedAt !== undefined) {
877 headers.ETag = etag;
878 headers["Last-Modified"] = modifiedAt.toUTCString();
879 }
880
881 // If-None-Match takes precedence over dates and uses weak comparison for
882 // GET/HEAD, including when compression changes the transferred bytes.
883 const ifNoneMatch = request.headers["if-none-match"];
884 const ifModifiedSince = request.headers["if-modified-since"];
885 const unchanged =
886 ifNoneMatch !== undefined
887 ? ifNoneMatch.split(",").some((value) => {
888 const candidate = value.trim();
889 return (
890 candidate === "*" ||
891 (etag !== undefined && candidate.replace(/^W\//i, "") === etag.slice(2))
892 );
893 })
894 : ifModifiedSince !== undefined &&
895 modifiedAt !== undefined &&
896 Date.parse(modifiedAt.toUTCString()) <= Date.parse(ifModifiedSince);
897 if (!isHtml && unchanged) {
898 return HttpServerResponse.empty({
899 status: 304,
900 headers: { ...headers, Vary: "Accept-Encoding" },
901 });
902 }
903
904 const contentType = isHtml ? "text/html; charset=utf-8" : mimeType;
905 // The request scope closes the handle for GET, HEAD, 304, errors, and cancellation.
906 // HEAD still passes through compression, which selects headers without reading the stream.
907 return HttpServerResponse.stream(streamStaticFile(opened.file, fileInfo.size), {
908 headers,
909 contentType,
910 contentLength: Number(fileInfo.size),
911 });
912 },
913 Effect.catchTags({
914 PlatformError: () =>
915 Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })),
916 }),
917);
918
919// Read the installed build's manifest once. Unknown files use revalidation.
920export const layerStaticAndDevRoute = Layer.unwrap(
921 loadImmutableBuildAssets.pipe(
922 Effect.map((assets) => HttpRouter.add("GET", "*", handleStaticAndDevRequest(assets))),
923 ),
924);