| @@ -1,5 +1,6 @@ |
| 1 | 1 | import { env } from "cloudflare:workers"; |
| 2 | 2 | import { createMiddleware, createStart } from "@tanstack/react-start"; |
| 3 | +import { Data, Effect, Schema } from "effect"; |
| 3 | 4 | import { handleApiRequest } from "./api"; |
| 4 | 5 | import { mcpFetch } from "./mcp"; |
| 5 | 6 | |
| @@ -72,7 +73,7 @@ const mcpRequestMiddleware = createMiddleware({ type: "request" }).server( · 66 unmodified lines |
| 72 | 73 | // --------------------------------------------------------------------------- |
| 73 | 74 | |
| 74 | 75 | const sentryTunnelMiddleware = createMiddleware({ type: "request" }).server( |
| 75 | | − async ({ pathname, request, next }) => { |
| 76 | + ({ pathname, request, next }) => { |
| 76 | 77 | if (pathname !== "/api/sentry-tunnel" || request.method !== "POST") { |
| 77 | 78 | return next(); |
| 78 | 79 | } |
| @@ -80,31 +81,62 @@ const sentryTunnelMiddleware = createMiddleware({ type: "request" }).server( · 1 unmodified line |
| 80 | 81 | const configuredDsn = (env as { SENTRY_DSN?: string }).SENTRY_DSN; |
| 81 | 82 | if (!configuredDsn) return new Response(null, { status: 204 }); |
| 82 | 83 | |
| 83 | | − try { |
| 84 | | − const envelope = await request.text(); |
| 85 | | − const firstLine = envelope.slice(0, envelope.indexOf("\n")); |
| 86 | | − const header = JSON.parse(firstLine) as { dsn?: string }; |
| 87 | | − if (!header.dsn) return new Response("missing dsn", { status: 400 }); |
| 88 | | − |
| 89 | | − const envelopeDsn = new URL(header.dsn); |
| 90 | | − const ourDsn = new URL(configuredDsn); |
| 91 | | − if (envelopeDsn.host !== ourDsn.host || envelopeDsn.pathname !== ourDsn.pathname) { |
| 92 | | − return new Response("dsn mismatch", { status: 400 }); |
| 93 | | − } |
| 94 | | − |
| 95 | | − const projectId = envelopeDsn.pathname.replace(/^\//, ""); |
| 96 | | − const ingestUrl = `https://${envelopeDsn.host}/api/${projectId}/envelope/`; |
| 97 | | − return fetch(ingestUrl, { |
| 98 | | − method: "POST", |
| 99 | | − body: envelope, |
| 100 | | − headers: { "Content-Type": "application/x-sentry-envelope" }, |
| 101 | | − }); |
| 102 | | − } catch { |
| 103 | | − return new Response("bad envelope", { status: 400 }); |
| 104 | | − } |
| 84 | + return Effect.runPromise(handleSentryTunnelRequest(request, configuredDsn)); |
| 105 | 85 | }, |
| 106 | 86 | ); |
| 107 | 87 | |
| 88 | +class SentryTunnelError extends Data.TaggedError("SentryTunnelError")<{ |
| 89 | + readonly cause?: unknown; |
| 90 | +}> {} |
| 91 | + |
| 92 | +const SentryEnvelopeHeader = Schema.Struct({ |
| 93 | + dsn: Schema.optional(Schema.String), |
| 94 | +}); |
| 95 | + |
| 96 | +const decodeSentryEnvelopeHeader = Schema.decodeUnknownEffect( |
| 97 | + Schema.fromJsonString(SentryEnvelopeHeader), |
| 98 | +); |
| 99 | + |
| 100 | +const badSentryEnvelopeResponse = () => new Response("bad envelope", { status: 400 }); |
| 101 | + |
| 102 | +const handleSentryTunnelRequest = (request: Request, configuredDsn: string) => |
| 103 | + Effect.gen(function* () { |
| 104 | + const envelope = yield* Effect.tryPromise({ |
| 105 | + try: () => request.text(), |
| 106 | + catch: (cause) => new SentryTunnelError({ cause }), |
| 107 | + }); |
| 108 | + const firstLine = envelope.slice(0, envelope.indexOf("\n")); |
| 109 | + const header = yield* decodeSentryEnvelopeHeader(firstLine).pipe( |
| 110 | + Effect.mapError((cause) => new SentryTunnelError({ cause })), |
| 111 | + ); |
| 112 | + const dsn = header.dsn; |
| 113 | + if (!dsn) return new Response("missing dsn", { status: 400 }); |
| 114 | + |
| 115 | + const envelopeDsn = yield* Effect.try({ |
| 116 | + try: () => new URL(dsn), |
| 117 | + catch: (cause) => new SentryTunnelError({ cause }), |
| 118 | + }); |
| 119 | + const ourDsn = yield* Effect.try({ |
| 120 | + try: () => new URL(configuredDsn), |
| 121 | + catch: (cause) => new SentryTunnelError({ cause }), |
| 122 | + }); |
| 123 | + if (envelopeDsn.host !== ourDsn.host || envelopeDsn.pathname !== ourDsn.pathname) { |
| 124 | + return new Response("dsn mismatch", { status: 400 }); |
| 125 | + } |
| 126 | + |
| 127 | + const projectId = envelopeDsn.pathname.replace(/^\//, ""); |
| 128 | + const ingestUrl = `https://${envelopeDsn.host}/api/${projectId}/envelope/`; |
| 129 | + return yield* Effect.tryPromise({ |
| 130 | + try: () => |
| 131 | + fetch(ingestUrl, { |
| 132 | + method: "POST", |
| 133 | + body: envelope, |
| 134 | + headers: { "Content-Type": "application/x-sentry-envelope" }, |
| 135 | + }), |
| 136 | + catch: (cause) => new SentryTunnelError({ cause }), |
| 137 | + }); |
| 138 | + }).pipe(Effect.catch(() => Effect.succeed(badSentryEnvelopeResponse()))); |
| 139 | + |
| 108 | 140 | // --------------------------------------------------------------------------- |
| 109 | 141 | // PostHog reverse proxy — the browser SDK targets a build-randomized |
| 110 | 142 | // first-party path and we forward to PostHog's ingest + asset hosts. Keeps |