| 1 | import * as Mime from "effect/http/Mime"; |
| 2 | import { |
| 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"; |
| 19 | import { isDevProxiedPath } from "@t3tools/shared/devProxy"; |
| 20 | import { injectMcpAppCookieBootstrap, injectMcpAppThemeBootstrap } from "@t3tools/shared/mcpApp"; |
| 21 | import { decodeOtlpTraceRecords } from "@t3tools/shared/observability"; |
| 22 | import * as Data from "effect/Data"; |
| 23 | import * as Effect from "effect/Effect"; |
| 24 | import * as Redacted from "effect/Redacted"; |
| 25 | import * as FileSystem from "effect/FileSystem"; |
| 26 | import * as Layer from "effect/Layer"; |
| 27 | import * as Option from "effect/Option"; |
| 28 | import * as Path from "effect/Path"; |
| 29 | import * as Schema from "effect/Schema"; |
| 30 | import * as Stream from "effect/Stream"; |
| 31 | import { cast } from "effect/Function"; |
| 32 | import { |
| 33 | HttpClient, |
| 34 | HttpClientResponse, |
| 35 | HttpMiddleware, |
| 36 | HttpRouter, |
| 37 | HttpServerResponse, |
| 38 | HttpServerRequest, |
| 39 | HttpServerRespondable, |
| 40 | } from "effect/http"; |
| 41 | import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; |
| 42 | import { OtlpTracer, OtlpSerialization } from "effect/observability"; |
| 43 | |
| 44 | import * as ServerConfig from "./config.ts"; |
| 45 | import * as ArchiveObjects from "./archive/ArchiveObjects.ts"; |
| 46 | import * as ArchiveAssetResponse from "./archive/ArchiveAssetResponse.ts"; |
| 47 | import * as TranscriptArchive from "./archive/TranscriptArchive.ts"; |
| 48 | import { ASSET_ROUTE_PREFIX, readMcpAppHtml, resolveAsset } from "./assets/AssetAccess.ts"; |
| 49 | import { githubMediaResponse } from "./assets/GitHubMediaFetch.ts"; |
| 50 | import { statMediaFile, streamMediaFile, type OpenMediaFile } from "./assets/MediaFile.ts"; |
| 51 | import { |
| 52 | ATTACHMENT_UPLOAD_ROUTE_PREFIX, |
| 53 | storeAttachmentUpload, |
| 54 | validateAttachmentUploadToken, |
| 55 | } from "./assets/AttachmentUpload.ts"; |
| 56 | import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; |
| 57 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; |
| 58 | import * as SharedAccounts from "./provider/SharedAccounts.ts"; |
| 59 | import * as SharedProfiles from "./provider/SharedProfiles.ts"; |
| 60 | import { |
| 61 | annotateEnvironmentRequest, |
| 62 | failEnvironmentScopeRequired, |
| 63 | failEnvironmentAuthInvalid, |
| 64 | failEnvironmentInternal, |
| 65 | } from "./auth/http.ts"; |
| 66 | import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; |
| 67 | import { WEBHOOK_ROUTE_PREFIX } from "./scheduledTasks/ScheduledTaskService.ts"; |
| 68 | import { |
| 69 | browserApiCorsAllowedHeaders, |
| 70 | browserApiCorsAllowedMethods, |
| 71 | DESKTOP_RENDERER_ORIGINS, |
| 72 | } from "./httpCors.ts"; |
| 73 | |
| 74 | const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; |
| 75 | const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); |
| 76 | const 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. |
| 83 | const 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. |
| 88 | const DOWNLOAD_MIME_TYPE_PATTERN = /^[\w!#$&^.+-]+\/[\w!#$&^.+-]+$/; |
| 89 | const isSafeDownloadMimeType = (mimeType: string): boolean => |
| 90 | DOWNLOAD_MIME_TYPE_PATTERN.test(mimeType) && |
| 91 | !/(?:^text\/html$|\/xml(?:$|-)|\+xml$)/i.test(mimeType.trim().toLowerCase()); |
| 92 | const isSafeInlineMediaMimeType = (mimeType: string): boolean => |
| 93 | DOWNLOAD_MIME_TYPE_PATTERN.test(mimeType) && /^(?:audio|video)\//i.test(mimeType); |
| 94 | const 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*`. */ |
| 98 | export 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 | |
| 115 | export 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. */ |
| 162 | function 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 | |
| 184 | export 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 | |
| 274 | export const layerHttpCompression = HttpRouter.middleware(HttpMiddleware.compression(), { |
| 275 | global: true, |
| 276 | }); |
| 277 | |
| 278 | export 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 | |
| 303 | export function isLoopbackHostname(hostname: string): boolean { |
| 304 | const normalizedHostname = hostname |
| 305 | .trim() |
| 306 | .toLowerCase() |
| 307 | .replace(/^\[(.*)\]$/, "$1"); |
| 308 | return LOOPBACK_HOSTNAMES.has(normalizedHostname); |
| 309 | } |
| 310 | |
| 311 | export 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 | |
| 319 | export 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 | |
| 343 | const isArchiveError = Schema.is(ArchiveError); |
| 344 | const isArchiveAuthError = Schema.is( |
| 345 | Schema.Union([ |
| 346 | EnvironmentAuthInvalidError, |
| 347 | EnvironmentInternalError, |
| 348 | EnvironmentScopeRequiredError, |
| 349 | ]), |
| 350 | ); |
| 351 | export 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 | |
| 369 | export 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 | |
| 393 | export 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 | |
| 411 | export 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 | |
| 457 | export 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 | |
| 493 | export 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 | |
| 526 | export 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 | |
| 541 | class 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. |
| 549 | export 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 | |
| 603 | const 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. |
| 611 | const 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 | |
| 618 | export const withUntracedRequests = Layer.provide(layerUntracedRequests); |
| 619 | |
| 620 | export 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 | |
| 692 | export 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 | |
| 731 | const 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 | |
| 744 | const 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 | |
| 767 | const 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 | |
| 777 | const 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 | |
| 789 | const 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. |
| 920 | export const layerStaticAndDevRoute = Layer.unwrap( |
| 921 | loadImmutableBuildAssets.pipe( |
| 922 | Effect.map((assets) => HttpRouter.add("GET", "*", handleStaticAndDevRequest(assets))), |
| 923 | ), |
| 924 | ); |