| 1 | import { ArchiveError } from "@t3tools/contracts"; |
| 2 | import * as ArchiveClient from "./ArchiveClient.ts"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Stream from "effect/Stream"; |
| 5 | import { HttpServerResponse } from "effect/http"; |
| 6 | import { |
| 7 | injectMcpAppCookieBootstrap, |
| 8 | injectMcpAppThemeBootstrap, |
| 9 | MCP_APP_MAX_HTML_BYTES, |
| 10 | } from "@t3tools/shared/mcpApp"; |
| 11 | |
| 12 | export const remoteResponse = Effect.fn("ArchiveAssetResponse.remoteResponse")(function* ( |
| 13 | asset: { |
| 14 | readonly location: string; |
| 15 | readonly hash: string; |
| 16 | readonly mimeType: string; |
| 17 | readonly download: boolean; |
| 18 | readonly fileName: string; |
| 19 | readonly renderIntent?: "mcp-app"; |
| 20 | }, |
| 21 | range: string | undefined, |
| 22 | ) { |
| 23 | const client = yield* ArchiveClient.ArchiveClient; |
| 24 | const result = yield* client.open(asset.location, asset.hash, range); |
| 25 | if (result.kind === "file") return result; |
| 26 | const response = result.response; |
| 27 | const headers: Record<string, string> = { |
| 28 | "content-type": asset.mimeType, |
| 29 | "cache-control": "private, no-store", |
| 30 | "x-content-type-options": "nosniff", |
| 31 | }; |
| 32 | for (const name of ["content-length", "content-range", "accept-ranges"]) { |
| 33 | const value = response.headers[name]; |
| 34 | if (value !== undefined) headers[name] = value; |
| 35 | } |
| 36 | if (asset.download) |
| 37 | headers["content-disposition"] = |
| 38 | `attachment; filename*=UTF-8''${encodeURIComponent(asset.fileName)}`; |
| 39 | if (asset.mimeType === "image/svg+xml") |
| 40 | headers["content-security-policy"] = "default-src 'none'; style-src 'unsafe-inline'; sandbox"; |
| 41 | if ( |
| 42 | asset.renderIntent === "mcp-app" && |
| 43 | !asset.download && |
| 44 | asset.mimeType.startsWith("text/html") |
| 45 | ) { |
| 46 | const parts: Array<Uint8Array> = []; |
| 47 | let length = 0; |
| 48 | yield* response.stream.pipe( |
| 49 | Stream.runForEach((bytes) => |
| 50 | Effect.gen(function* () { |
| 51 | length += bytes.length; |
| 52 | if (length > MCP_APP_MAX_HTML_BYTES) |
| 53 | return yield* new ArchiveError({ reason: "integrity" }); |
| 54 | parts.push(bytes); |
| 55 | }), |
| 56 | ), |
| 57 | ); |
| 58 | const bytes = new Uint8Array(length); |
| 59 | let offset = 0; |
| 60 | for (const part of parts) { |
| 61 | bytes.set(part, offset); |
| 62 | offset += part.length; |
| 63 | } |
| 64 | const body = injectMcpAppCookieBootstrap( |
| 65 | injectMcpAppThemeBootstrap(new TextDecoder().decode(bytes)), |
| 66 | ); |
| 67 | delete headers["content-length"]; |
| 68 | return { kind: "response" as const, response: HttpServerResponse.text(body, { headers }) }; |
| 69 | } |
| 70 | return { |
| 71 | kind: "response" as const, |
| 72 | response: HttpServerResponse.stream(response.stream, { status: response.status, headers }), |
| 73 | }; |
| 74 | }); |