| @@ -1,5 +1,6 @@ |
| 1 | import { env } from "cloudflare:workers"; | 1 | import { env } from "cloudflare:workers"; |
| 2 | import { createMiddleware, createStart } from "@tanstack/react-start"; | 2 | import { createMiddleware, createStart } from "@tanstack/react-start"; |
| | 3 | import { Data, Effect, Schema } from "effect"; |
| 3 | import { handleApiRequest } from "./api"; | 4 | import { handleApiRequest } from "./api"; |
| 4 | import { mcpFetch } from "./mcp"; | 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 | const sentryTunnelMiddleware = createMiddleware({ type: "request" }).server( | 75 | const sentryTunnelMiddleware = createMiddleware({ type: "request" }).server( |
| 75 | async ({ pathname, request, next }) => { | 76 | ({ pathname, request, next }) => { |
| 76 | if (pathname !== "/api/sentry-tunnel" || request.method !== "POST") { | 77 | if (pathname !== "/api/sentry-tunnel" || request.method !== "POST") { |
| 77 | return next(); | 78 | return next(); |
| 78 | } | 79 | } |
| @@ -80,31 +81,62 @@ const sentryTunnelMiddleware = createMiddleware({ type: "request" }).server( · 1 unmodified line |
| 80 | const configuredDsn = (env as { SENTRY_DSN?: string }).SENTRY_DSN; | 81 | const configuredDsn = (env as { SENTRY_DSN?: string }).SENTRY_DSN; |
| 81 | if (!configuredDsn) return new Response(null, { status: 204 }); | 82 | if (!configuredDsn) return new Response(null, { status: 204 }); |
| 82 | | 83 | |
| 83 | try { | 84 | return Effect.runPromise(handleSentryTunnelRequest(request, configuredDsn)); |
| 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 | } | | |
| 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 | // PostHog reverse proxy — the browser SDK targets a build-randomized | 141 | // PostHog reverse proxy — the browser SDK targets a build-randomized |
| 110 | // first-party path and we forward to PostHog's ingest + asset hosts. Keeps | 142 | // first-party path and we forward to PostHog's ingest + asset hosts. Keeps |