Open History to see the last commit
1import * as Mime from "effect/http/Mime";
2import {
3 AuthOrchestrationOperateScope,
4 AuthOrchestrationReadScope,
5 EnvironmentHttpApi,
6} from "@t3tools/contracts";
7import { isDevProxiedPath } from "@t3tools/shared/devProxy";
8import { injectMcpAppCookieBootstrap, injectMcpAppThemeBootstrap } from "@t3tools/shared/mcpApp";
9import { decodeOtlpTraceRecords } from "@t3tools/shared/observability";
10import * as ByteSize from "effect/ByteSize";
11import * as Effect from "effect/Effect";
12import * as FileSystem from "effect/FileSystem";
13import * as Layer from "effect/Layer";
14import * as Option from "effect/Option";
15import * as Path from "effect/Path";
16import * as Schema from "effect/Schema";
17import * as Stream from "effect/Stream";
18import { cast, pipe } from "effect/Function";
19import {
20 HttpClient,
21 HttpClientResponse,
22 HttpMiddleware,
23 HttpRouter,
24 HttpServerResponse,
25 HttpServerRequest,
26 HttpServerRespondable,
27} from "effect/http";
28import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
29import { OtlpTracer, OtlpSerialization } from "effect/observability";
30
31import * as ServerConfig from "./config.ts";
32import { ASSET_ROUTE_PREFIX, readMcpAppHtml, resolveAsset } from "./assets/AssetAccess.ts";
33import { sourceControlMediaResponse } from "./assets/sourceControlMediaResponse.ts";
34import { statMediaFile, streamMediaFile, type OpenMediaFile } from "./assets/MediaFile.ts";
35import {
36 ATTACHMENT_UPLOAD_ROUTE_PREFIX,
37 storeAttachmentUpload,
38 validateAttachmentUploadToken,
39} from "./assets/AttachmentUpload.ts";
40import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts";
41import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
42import { traceRelayRequest } from "./cloud/traceRelayRequest.ts";
43import {
44 annotateEnvironmentRequest,
45 failEnvironmentScopeRequired,
46 failEnvironmentAuthInvalid,
47 failEnvironmentInternal,
48} from "./auth/http.ts";
49import * as ServerEnvironment from "./environment/ServerEnvironment.ts";
50import { WEBHOOK_ROUTE_PREFIX } from "./scheduledTasks/ScheduledTaskService.ts";
51import {
52 browserApiCorsAllowedHeaders,
53 browserApiCorsAllowedMethods,
54 DESKTOP_RENDERER_ORIGINS,
55} from "./httpCors.ts";
56import { requestUrl } from "./requestUrl.ts";
57
58const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces";
59const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);
60const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
61// Opaque origin blocks cookies, storage and API access while allowing scripts.
62// Agent HTML can open without a click, so disallow blocking modals.
63const HTML_CONTENT_SECURITY_POLICY =
64 "sandbox allow-scripts allow-forms allow-popups allow-downloads";
65
66// Proxies may strip disposition; force document-capable downloads to octet-stream.
67const DOWNLOAD_MIME_TYPE_PATTERN = /^[\w!#$&^.+-]+\/[\w!#$&^.+-]+$/;
68const isSafeDownloadMimeType = (mimeType: string): boolean =>
69 DOWNLOAD_MIME_TYPE_PATTERN.test(mimeType) &&
70 !/(?:^text\/html$|\/xml(?:$|-)|\+xml$)/i.test(mimeType.trim().toLowerCase());
71const isSafeInlineMediaMimeType = (mimeType: string): boolean =>
72 DOWNLOAD_MIME_TYPE_PATTERN.test(mimeType) && /^(?:audio|video)\//i.test(mimeType);
73const isSafeInlineDocumentMimeType = (mimeType: string): boolean =>
74 mimeType.toLowerCase() === "application/pdf" || mimeType.toLowerCase() === "text/html";
75
76export function downloadContentDisposition(fileName?: string): string {
77 if (fileName === undefined) {
78 return "attachment";
79 }
80 // toWellFormed: encodeURIComponent throws URIError on unpaired surrogates.
81 const sanitized = fileName.toWellFormed().replace(/[\p{Cc}"\\]/gu, "_");
82 const asciiFallback = sanitized.replace(/[^\u0020-\u007e]/g, "_");
83 const needsExtended = asciiFallback !== sanitized;
84 const extendedName = encodeURIComponent(sanitized).replace(
85 /['()*]/g,
86 (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`,
87 );
88 return `attachment; filename="${asciiFallback}"${
89 needsExtended ? `; filename*=UTF-8''${extendedName}` : ""
90 }`;
91}
92
93export function assetResponseHeaders(
94 filePath: string,
95 options?: {
96 readonly download?: boolean;
97 readonly fileName?: string;
98 readonly mimeType?: string;
99 },
100): Record<string, string> {
101 const lowerPath = filePath.toLowerCase();
102 const inlineMimeType = options?.mimeType?.split(";", 1)[0]?.trim();
103 return {
104 "Cache-Control": "private, max-age=3600",
105 "X-Content-Type-Options": "nosniff",
106 ...(options?.download
107 ? {
108 "Content-Disposition": downloadContentDisposition(options.fileName),
109 "Content-Security-Policy": "default-src 'none'; sandbox",
110 "Content-Type":
111 options.mimeType !== undefined && isSafeDownloadMimeType(options.mimeType)
112 ? options.mimeType
113 : "application/octet-stream",
114 }
115 : inlineMimeType !== undefined && isSafeInlineMediaMimeType(inlineMimeType)
116 ? { "Content-Type": inlineMimeType }
117 : inlineMimeType !== undefined && isSafeInlineDocumentMimeType(inlineMimeType)
118 ? {
119 "Content-Type":
120 inlineMimeType.toLowerCase() === "text/html"
121 ? "text/html; charset=utf-8"
122 : "application/pdf",
123 ...(inlineMimeType.toLowerCase() === "text/html"
124 ? { "Content-Security-Policy": HTML_CONTENT_SECURITY_POLICY }
125 : {}),
126 }
127 : lowerPath.endsWith(".html") || lowerPath.endsWith(".htm")
128 ? {
129 "Content-Type": "text/html; charset=utf-8",
130 "Content-Security-Policy": HTML_CONTENT_SECURITY_POLICY,
131 }
132 : {}),
133 ...(!options?.download && lowerPath.endsWith(".svg")
134 ? { "Content-Security-Policy": SVG_CONTENT_SECURITY_POLICY }
135 : {}),
136 };
137}
138
139function assetByteRange(header: string, size: bigint) {
140 const match = /^bytes=(\d*)-(\d*)$/i.exec(header.trim());
141 if (!match || (!match[1] && !match[2])) return null;
142 const first = match[1] ? BigInt(match[1]) : null;
143 const last = match[2] ? BigInt(match[2]) : null;
144 if (first !== null && last !== null && last < first) return null;
145 if (size === 0n || (first !== null && first >= size) || (first === null && last === 0n)) {
146 return { _tag: "Unsatisfiable" as const };
147 }
148 const start = first ?? (last! >= size ? 0n : size - last!);
149 const end = first === null || last === null || last >= size ? size - 1n : last;
150 if (!Number.isSafeInteger(Number(start)) || !Number.isSafeInteger(Number(end))) {
151 return { _tag: "Unsatisfiable" as const };
152 }
153 return {
154 _tag: "Range" as const,
155 offset: start,
156 bytesToRead: end - start + 1n,
157 contentRange: `bytes ${start}-${end}/${size}`,
158 };
159}
160
161export const assetFileResponse = Effect.fn("assetFileResponse")(function* (
162 asset: {
163 readonly path: string;
164 readonly download?: boolean;
165 readonly fileName?: string;
166 readonly mimeType?: string;
167 readonly file?: OpenMediaFile;
168 readonly renderIntent?: "mcp-app";
169 },
170 rangeHeader?: string,
171 ifRangeHeader?: string,
172 method: "GET" | "HEAD" = "GET",
173) {
174 const headers = assetResponseHeaders(asset.path, asset);
175 if (
176 asset.renderIntent === "mcp-app" &&
177 !asset.download &&
178 headers["Content-Type"] === "text/html; charset=utf-8"
179 ) {
180 const bytes = yield* readMcpAppHtml(asset.path);
181 if (bytes === null) {
182 return HttpServerResponse.text("MCP app is too large to preview.", { status: 413 });
183 }
184 const body = pipe(
185 new TextDecoder().decode(bytes),
186 injectMcpAppThemeBootstrap,
187 injectMcpAppCookieBootstrap,
188 (html) => new TextEncoder().encode(html),
189 );
190 headers["Content-Length"] = String(body.byteLength);
191 return method === "HEAD"
192 ? HttpServerResponse.empty({ status: 200, headers })
193 : HttpServerResponse.uint8Array(body, { headers });
194 }
195 const mediaFile = asset.file;
196 const mediaInfo = mediaFile ? yield* statMediaFile(asset.path, mediaFile) : undefined;
197 const isMedia = /^(?:audio|video)\//i.test(headers["Content-Type"] ?? "");
198 if (isMedia) {
199 // Without file identity, validators cannot guarantee byte equality; attachments must also expire with their URL.
200 headers["Cache-Control"] = "private, no-store";
201 }
202 let status = 200;
203 let offset = 0n;
204 let bytesToRead: bigint | undefined;
205 {
206 headers["Accept-Ranges"] = "bytes";
207 // If-Range requires a matching validator. A full response is safe when we cannot validate it.
208 if (method === "GET" && rangeHeader && ifRangeHeader === undefined) {
209 const fs = yield* FileSystem.FileSystem;
210 const info = mediaInfo ?? (yield* fs.stat(asset.path));
211 const range = assetByteRange(rangeHeader, info.size);
212 if (range?._tag === "Unsatisfiable") {
213 return HttpServerResponse.empty({
214 status: 416,
215 headers: { ...headers, "Content-Range": `bytes */${info.size}` },
216 });
217 }
218 if (range?._tag === "Range") {
219 status = 206;
220 offset = range.offset;
221 bytesToRead = range.bytesToRead;
222 headers["Content-Range"] = range.contentRange;
223 }
224 }
225 }
226 if (mediaFile && mediaInfo) {
227 const size = bytesToRead ?? mediaInfo.size;
228 headers["Content-Type"] ??= Option.getOrElse(
229 Mime.getType(asset.path),
230 () => "application/octet-stream",
231 );
232 headers["Content-Length"] = String(size);
233 if (!isMedia) {
234 headers["Last-Modified"] = mediaInfo.mtime.toUTCString();
235 headers.ETag = `W/"${mediaInfo.size.toString(16)}-${mediaInfo.mtimeMs.toString(16)}"`;
236 }
237 if (method === "HEAD" || size === 0n) {
238 return HttpServerResponse.empty({ status, headers });
239 }
240 const body = streamMediaFile(mediaFile, offset, size);
241 if (!body) {
242 return HttpServerResponse.text("File is too large to preview.", { status: 413 });
243 }
244 return HttpServerResponse.stream(body, {
245 status,
246 headers,
247 });
248 }
249 return yield* HttpServerResponse.file(asset.path, { status, offset, bytesToRead, headers });
250});
251
252export const layerHttpCompression = HttpRouter.middleware(HttpMiddleware.compression(), {
253 global: true,
254});
255
256export const layerBrowserApiCors = Layer.unwrap(
257 Effect.gen(function* () {
258 const config = yield* ServerConfig.ServerConfig;
259 const devOrigin = config.devUrl?.origin;
260 // Credentialed dev requests need explicit Vite/Electron origins; packaged desktop uses the default wildcard.
261 return HttpRouter.cors({
262 ...(devOrigin
263 ? {
264 allowedOrigins: [devOrigin, ...DESKTOP_RENDERER_ORIGINS, ...config.devAllowedOrigins],
265 credentials: true,
266 }
267 : {}),
268 allowedMethods: browserApiCorsAllowedMethods,
269 allowedHeaders: browserApiCorsAllowedHeaders,
270 maxAge: 600,
271 });
272 }),
273);
274
275function isLoopbackHostname(hostname: string): boolean {
276 const normalizedHostname = hostname
277 .trim()
278 .toLowerCase()
279 .replace(/^\[(.*)\]$/, "$1");
280 return LOOPBACK_HOSTNAMES.has(normalizedHostname);
281}
282
283function resolveDevRedirectUrl(devUrl: URL, requestUrl: URL): string {
284 const redirectUrl = new URL(devUrl.toString());
285 redirectUrl.pathname = requestUrl.pathname;
286 redirectUrl.search = requestUrl.search;
287 redirectUrl.hash = requestUrl.hash;
288 return redirectUrl.toString();
289}
290
291const authenticateRawRouteWithScope =
292 /* Request instrumentation already owns the span; keep this helper in that context. */
293 Effect.fnUntraced(function* (
294 scope: typeof AuthOrchestrationReadScope | typeof AuthOrchestrationOperateScope,
295 ) {
296 const request = yield* HttpServerRequest.HttpServerRequest;
297 const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
298 const session = yield* serverAuth.authenticateHttpRequest(request).pipe(
299 Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) =>
300 failEnvironmentAuthInvalid(
301 EnvironmentAuth.serverAuthCredentialReason(error),
302 EnvironmentAuth.serverAuthDpopFailureReason(error),
303 ),
304 ),
305 Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) =>
306 failEnvironmentInternal("internal_error", error),
307 ),
308 );
309 if (!session.scopes.includes(scope)) {
310 return yield* failEnvironmentScopeRequired(scope);
311 }
312 });
313
314export const layerServerEnvironmentHttpApi = HttpApiBuilder.group(
315 EnvironmentHttpApi,
316 "metadata",
317 Effect.fnUntraced(function* (handlers) {
318 const serverEnvironment = yield* ServerEnvironment.ServerEnvironment;
319 return handlers.handle(
320 "descriptor",
321 Effect.fn("environment.metadata.descriptor")(function* (args) {
322 yield* annotateEnvironmentRequest(args.endpoint.name);
323 return yield* serverEnvironment.getDescriptor;
324 }, traceRelayRequest),
325 );
326 }),
327);
328
329class DecodeOtlpTraceRecordsError extends Schema.TaggedError<DecodeOtlpTraceRecordsError>()(
330 "DecodeOtlpTraceRecordsError",
331 { cause: Schema.Defect() },
332) {}
333
334// Tracing once-per-second renderer exports would create more spans than it forwards.
335export const layerOtlpTracesProxyRoute = HttpRouter.add(
336 "POST",
337 OTLP_TRACES_PROXY_PATH,
338 Effect.gen(function* () {
339 yield* authenticateRawRouteWithScope(AuthOrchestrationOperateScope);
340 const request = yield* HttpServerRequest.HttpServerRequest;
341 const config = yield* ServerConfig.ServerConfig;
342 const otlpTracesUrl = config.otlpTracesUrl;
343 const otlpHeaders = config.otlpTracesExport.headers;
344 const browserTraceCollector = yield* BrowserTraceCollector.BrowserTraceCollector;
345 const httpClient = yield* HttpClient.HttpClient;
346 const serialization = yield* OtlpSerialization.OtlpSerialization;
347 const bodyJson = cast<unknown, OtlpTracer.TraceData>(yield* request.json);
348
349 yield* Effect.try({
350 try: () => decodeOtlpTraceRecords(bodyJson),
351 catch: (cause) => new DecodeOtlpTraceRecordsError({ cause }),
352 }).pipe(
353 Effect.flatMap((records) => browserTraceCollector.record(records)),
354 Effect.catch((cause) => Effect.logWarning("Failed to decode browser OTLP traces", { cause })),
355 );
356
357 if (otlpTracesUrl === undefined) {
358 return HttpServerResponse.empty({ status: 204 });
359 }
360
361 return yield* httpClient
362 .post(otlpTracesUrl, {
363 body: serialization.traces(bodyJson),
364 headers: otlpHeaders,
365 })
366 .pipe(
367 Effect.flatMap(HttpClientResponse.filterStatusOk),
368 Effect.as(HttpServerResponse.empty({ status: 204 })),
369 Effect.tapError((cause) =>
370 Effect.logWarning("Failed to export browser OTLP traces", {
371 cause,
372 otlpTracesUrl,
373 }),
374 ),
375 Effect.orElseSucceed(() =>
376 HttpServerResponse.text("Trace export failed.", { status: 502 }),
377 ),
378 );
379 }).pipe(
380 Effect.catchTags({
381 EnvironmentAuthInvalidError: HttpServerRespondable.toResponse,
382 EnvironmentInternalError: HttpServerRespondable.toResponse,
383 EnvironmentScopeRequiredError: HttpServerRespondable.toResponse,
384 }),
385 Effect.withTracerEnabled(false),
386 ),
387);
388
389const UNTRACED_REQUEST_PATHS: ReadonlySet<string> = new Set([OTLP_TRACES_PROXY_PATH]);
390
391// HttpRouter.serve builds routes privately; provide the server-wide TracerDisabledWhen to the served layer.
392const layerUntracedRequests = Layer.succeed(HttpMiddleware.TracerDisabledWhen)((request) => {
393 const queryIndex = request.url.indexOf("?");
394 const path = queryIndex === -1 ? request.url : request.url.slice(0, queryIndex);
395 // Signed URLs carry bearer tokens in the path, so they never reach a trace.
396 return (
397 UNTRACED_REQUEST_PATHS.has(path) ||
398 path.startsWith(`${WEBHOOK_ROUTE_PREFIX}/`) ||
399 path.startsWith(`${ASSET_ROUTE_PREFIX}/`) ||
400 path.startsWith(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`)
401 );
402});
403
404export const withUntracedRequests = Layer.provide(layerUntracedRequests);
405
406export const layerAssetRoute = HttpRouter.add(
407 "GET",
408 `${ASSET_ROUTE_PREFIX}/*`,
409 Effect.gen(function* () {
410 const request = yield* HttpServerRequest.HttpServerRequest;
411 const url = requestUrl(request);
412 if (Option.isNone(url)) {
413 return HttpServerResponse.text("Bad Request", { status: 400 });
414 }
415
416 const suffix = url.value.pathname.slice(`${ASSET_ROUTE_PREFIX}/`.length);
417 const separatorIndex = suffix.indexOf("/");
418 if (separatorIndex <= 0) {
419 return HttpServerResponse.text("Not Found", { status: 404 });
420 }
421
422 const asset = yield* resolveAsset(
423 suffix.slice(0, separatorIndex),
424 suffix.slice(separatorIndex + 1),
425 );
426 if (!asset) {
427 return HttpServerResponse.text("Not Found", { status: 404 });
428 }
429 if (asset.kind === "bytes") {
430 return HttpServerResponse.uint8Array(asset.bytes, {
431 contentType: asset.mimeType,
432 headers: { "cache-control": "private, max-age=3600", "x-content-type-options": "nosniff" },
433 });
434 }
435 if (asset.kind === "source-control-media") {
436 return yield* sourceControlMediaResponse(asset, request.headers).pipe(
437 Effect.tapError((cause) =>
438 // The URL can carry a token in its query string, and the error keeps the request it
439 // failed on; the host and the failure kind are enough to diagnose.
440 Effect.logWarning("Failed to fetch source control media.", {
441 host: URL.canParse(asset.url) ? new URL(asset.url).host : undefined,
442 reason: cause.reason._tag,
443 }),
444 ),
445 Effect.orElseSucceed(() =>
446 HttpServerResponse.empty({
447 status: 502,
448 headers: { "cache-control": "private, no-store", "x-content-type-options": "nosniff" },
449 }),
450 ),
451 );
452 }
453 return yield* assetFileResponse(
454 asset,
455 request.method === "GET" ? request.headers.range : undefined,
456 request.headers["if-range"],
457 request.method === "HEAD" ? "HEAD" : "GET",
458 ).pipe(
459 Effect.orElseSucceed(() => HttpServerResponse.text("Internal Server Error", { status: 500 })),
460 );
461 }),
462);
463
464export const layerAttachmentUploadRoute = HttpRouter.add(
465 "POST",
466 `${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/*`,
467 Effect.gen(function* () {
468 const request = yield* HttpServerRequest.HttpServerRequest;
469 const url = requestUrl(request);
470 if (Option.isNone(url)) {
471 return HttpServerResponse.text("Bad Request", { status: 400 });
472 }
473
474 const token = url.value.pathname.slice(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`.length);
475 if (!token) {
476 return HttpServerResponse.text("Not Found", { status: 404 });
477 }
478 const claims = yield* validateAttachmentUploadToken(token);
479 if (!claims) {
480 return HttpServerResponse.text("Not Found", { status: 404 });
481 }
482
483 const contentLengthHeader = request.headers["content-length"];
484 if (
485 contentLengthHeader !== undefined &&
486 (!Number.isInteger(Number(contentLengthHeader)) ||
487 Number(contentLengthHeader) !== claims.sizeBytes)
488 ) {
489 return HttpServerResponse.text("Content-Length must match the upload size.", {
490 status: 400,
491 });
492 }
493
494 // Keep the request stream in the route scope until the response is sent.
495 const bodyPull = yield* Stream.toPull(request.stream);
496 const stored = yield* storeAttachmentUpload(claims, Stream.fromPull(Effect.succeed(bodyPull)));
497 return stored.ok
498 ? HttpServerResponse.empty({ status: 204 })
499 : HttpServerResponse.text(stored.detail, { status: stored.status });
500 }),
501);
502
503const decodeBuildManifest = Schema.decodeUnknownEffect(
504 Schema.fromJsonString(
505 Schema.Record(
506 Schema.String,
507 Schema.Struct({
508 file: Schema.String,
509 css: Schema.optional(Schema.Array(Schema.String)),
510 assets: Schema.optional(Schema.Array(Schema.String)),
511 }),
512 ),
513 ),
514);
515
516const loadImmutableBuildAssets = Effect.gen(function* () {
517 const config = yield* ServerConfig.ServerConfig;
518 const staticDir =
519 config.staticDir ?? (config.devUrl ? yield* ServerConfig.resolveStaticDir() : undefined);
520 if (!staticDir) return new Set<string>();
521 const fileSystem = yield* FileSystem.FileSystem;
522 const path = yield* Path.Path;
523 return yield* fileSystem.readFileString(path.join(staticDir, ".vite", "manifest.json")).pipe(
524 Effect.flatMap(decodeBuildManifest),
525 Effect.map(
526 (manifest) =>
527 new Set(
528 Object.values(manifest).flatMap((entry) => [
529 entry.file,
530 ...(entry.css ?? []),
531 ...(entry.assets ?? []),
532 ]),
533 ),
534 ),
535 Effect.orElseSucceed(() => new Set<string>()),
536 );
537});
538
539const openStaticFile = Effect.fn("openStaticFile")(function* (filePath: string) {
540 const fileSystem = yield* FileSystem.FileSystem;
541 const pathInfo = yield* fileSystem.stat(filePath).pipe(Effect.orElseSucceed(() => null));
542 if (pathInfo?.type !== "File") return null;
543 const file = yield* fileSystem.open(filePath, { flag: "r" }).pipe(
544 Effect.catchTags({
545 PlatformError: (error) =>
546 error.reason._tag === "NotFound" ? Effect.succeed(null) : Effect.fail(error),
547 }),
548 );
549 // Electron asar can stat and read files even when opening a handle returns NotFound.
550 // Refresh path metadata because the file may have been replaced since the first stat.
551 const info = yield* file === null ? fileSystem.stat(filePath) : file.stat;
552 return info.type === "File" ? { file, info } : null;
553});
554
555const streamStaticFile = (file: FileSystem.File, size: bigint) =>
556 Stream.unfold(
557 0n,
558 Effect.fnUntraced(function* (offset: bigint) {
559 if (offset >= size) return;
560 const remaining = size - offset;
561 const bytes = yield* file.readAlloc(Number(remaining < 65_536n ? remaining : 65_536n));
562 if (Option.isNone(bytes)) return;
563 return [bytes.value, offset + BigInt(bytes.value.byteLength)] as const;
564 }),
565 );
566
567const handleStaticAndDevRequest = Effect.fn("handleStaticAndDevRequest")(
568 function* (immutableBuildAssets: ReadonlySet<string>) {
569 const request = yield* HttpServerRequest.HttpServerRequest;
570 const url = requestUrl(request);
571
572 if (Option.isNone(url)) {
573 return HttpServerResponse.text("Bad Request", { status: 400 });
574 }
575
576 const config = yield* ServerConfig.ServerConfig;
577 if (config.devUrl && isDevProxiedPath(url.value.pathname)) {
578 return HttpServerResponse.text("Not Found", { status: 404 });
579 }
580
581 if (config.devUrl && isLoopbackHostname(url.value.hostname)) {
582 return HttpServerResponse.redirect(resolveDevRedirectUrl(config.devUrl, url.value), {
583 status: 302,
584 });
585 }
586
587 const staticDir =
588 config.staticDir ?? (config.devUrl ? yield* ServerConfig.resolveStaticDir() : undefined);
589 if (!staticDir && config.devUrl) {
590 // `vp run dev --share --built` prints the pairing URL before its first
591 // web build lands in apps/web/dist. Reload until it does.
592 return HttpServerResponse.text(
593 '<!doctype html><meta http-equiv="refresh" content="2"><p>Waiting for the first web build. This page reloads by itself.</p>',
594 { status: 503, contentType: "text/html", headers: { "Retry-After": "2" } },
595 );
596 }
597 if (!staticDir) {
598 return HttpServerResponse.text("No static directory configured and no dev URL set.", {
599 status: 503,
600 });
601 }
602
603 const path = yield* Path.Path;
604 const staticRoot = path.resolve(staticDir);
605 const staticRequestPath = url.value.pathname === "/" ? "/index.html" : url.value.pathname;
606 const rawStaticRelativePath = staticRequestPath.replace(/^[/\\]+/, "");
607 const hasRawLeadingParentSegment = rawStaticRelativePath.startsWith("..");
608 const staticRelativePath = path.normalize(rawStaticRelativePath).replace(/^[/\\]+/, "");
609 const hasPathTraversalSegment = staticRelativePath.startsWith("..");
610 if (
611 staticRelativePath.length === 0 ||
612 hasRawLeadingParentSegment ||
613 hasPathTraversalSegment ||
614 staticRelativePath.includes("\0")
615 ) {
616 return HttpServerResponse.text("Invalid static file path", { status: 400 });
617 }
618
619 const isWithinStaticRoot = (candidate: string) =>
620 candidate === staticRoot ||
621 candidate.startsWith(staticRoot.endsWith(path.sep) ? staticRoot : `${staticRoot}${path.sep}`);
622
623 let filePath = path.resolve(staticRoot, staticRelativePath);
624 if (!isWithinStaticRoot(filePath)) {
625 return HttpServerResponse.text("Invalid static file path", { status: 400 });
626 }
627
628 const ext = path.extname(filePath);
629 if (!ext) {
630 filePath = path.resolve(filePath, "index.html");
631 if (!isWithinStaticRoot(filePath)) {
632 return HttpServerResponse.text("Invalid static file path", { status: 400 });
633 }
634 }
635
636 let opened = yield* openStaticFile(filePath);
637 if (!opened) {
638 filePath = path.resolve(staticRoot, "index.html");
639 opened = yield* openStaticFile(filePath);
640 if (!opened) {
641 return HttpServerResponse.text("Not Found", { status: 404 });
642 }
643 }
644 const fileInfo = opened.info;
645 const mimeType = Option.getOrElse(Mime.getType(filePath), () => "application/octet-stream");
646 const isHtml = mimeType === "text/html";
647
648 // A hash-like name is not enough: custom static files can use the same naming pattern.
649 const relativePath = path.relative(staticRoot, filePath).replaceAll("\\", "/");
650 const immutable =
651 !isHtml &&
652 /^assets\/.+-[\w-]{8}\.[^/]+$/.test(relativePath) &&
653 immutableBuildAssets.has(relativePath);
654 const cacheControl = immutable ? "public, max-age=31536000, immutable" : "no-cache";
655 const validators = (info: FileSystem.File.Info) => {
656 // Deployments can preserve HTML size and mtime while changing its bundle URLs.
657 const modifiedAt = isHtml ? undefined : Option.getOrUndefined(info.mtime);
658 return modifiedAt
659 ? {
660 ETag: `W/"${info.size.toString(16)}-${modifiedAt.getTime().toString(16)}"`,
661 "Last-Modified": modifiedAt.toUTCString(),
662 }
663 : {};
664 };
665 const headers: Record<string, string> = {
666 "Cache-Control": cacheControl,
667 ...validators(fileInfo),
668 };
669 const etag = headers.ETag;
670 const lastModified = headers["Last-Modified"];
671
672 // If-None-Match precedes dates and uses weak GET/HEAD comparison even across compression.
673 const ifNoneMatch = request.headers["if-none-match"];
674 const ifModifiedSince = request.headers["if-modified-since"];
675 const unchanged =
676 ifNoneMatch !== undefined
677 ? ifNoneMatch.split(",").some((value) => {
678 const candidate = value.trim();
679 return (
680 candidate === "*" ||
681 (etag !== undefined && candidate.replace(/^W\//i, "") === etag.slice(2))
682 );
683 })
684 : ifModifiedSince !== undefined &&
685 lastModified !== undefined &&
686 Date.parse(lastModified) <= Date.parse(ifModifiedSince);
687 if (!isHtml && unchanged) {
688 return HttpServerResponse.empty({
689 status: 304,
690 headers: { ...headers, Vary: "Accept-Encoding" },
691 });
692 }
693
694 const contentType = isHtml ? "text/html; charset=utf-8" : mimeType;
695 if (opened.file === null && request.method !== "HEAD") {
696 const fileSystem = yield* FileSystem.FileSystem;
697 // Complete fallback reads before success headers; stream errors arrive too late for a 500.
698 const bytes = yield* fileSystem.readFile(filePath);
699 const info = yield* fileSystem.stat(filePath);
700 return HttpServerResponse.uint8Array(bytes, {
701 headers: {
702 "Cache-Control": cacheControl,
703 ...validators({ ...info, size: ByteSize.bytes(bytes.byteLength) }),
704 },
705 contentType,
706 });
707 }
708 // The request scope closes the handle for GET, HEAD, 304, errors, and cancellation.
709 // HEAD still passes through compression, which selects headers without reading the stream.
710 return HttpServerResponse.stream(
711 opened.file === null ? Stream.empty : streamStaticFile(opened.file, fileInfo.size),
712 {
713 headers,
714 contentType,
715 contentLength: Number(fileInfo.size),
716 },
717 );
718 },
719 Effect.catchTags({
720 PlatformError: () =>
721 Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })),
722 }),
723);
724
725export const layerStaticAndDevRoute = Layer.unwrap(
726 loadImmutableBuildAssets.pipe(
727 Effect.map((assets) => HttpRouter.add("GET", "*", handleStaticAndDevRequest(assets))),
728 ),
729);