11import { getEnvelopeEndpointWithUrlEncodedAuth } from '../api' ;
2+ import { getClient } from '../currentScopes' ;
3+ import { suppressTracing } from '../tracing/trace' ;
4+ import type { DsnLike } from '../types/dsn' ;
5+ import type { TunnelOptions } from '../types/options' ;
26import { debug } from './debug-logger' ;
3- import { makeDsn } from './dsn' ;
7+ import { dsnToString , makeDsn } from './dsn' ;
48import { parseEnvelope } from './envelope' ;
59
610export interface HandleTunnelRequestOptions {
711 /** Incoming request containing the Sentry envelope as its body */
812 request : Request ;
9- /** Pre-parsed array of allowed DSN strings */
10- allowedDsns : Array < string > ;
13+ /** DSNs whose envelopes may be forwarded. Defaults to the DSN of the active client. */
14+ allowedDsns ?: Array < string > ;
15+ }
16+
17+ /**
18+ * Splits the `tunnel` option a server SDK's `init` received. A URL stays the transport tunnel for the server's own
19+ * envelopes. `true` or {@link TunnelOptions} enables serving the managed tunnel instead.
20+ */
21+ export function resolveServerTunnelOption ( tunnel : string | true | TunnelOptions | undefined ) : {
22+ tunnel : string | undefined ;
23+ _managedTunnel ?: TunnelOptions ;
24+ } {
25+ if ( typeof tunnel === 'string' || ! tunnel ) {
26+ return { tunnel } ;
27+ }
28+
29+ return { tunnel : undefined , _managedTunnel : tunnel === true ? { } : tunnel } ;
30+ }
31+
32+ /**
33+ * Returns the same-origin tunnel path for a DSN, e.g. `/k3x9q2a`.
34+ *
35+ * Browser and server SDKs derive the path independently from the DSN, so no build step has to pass it
36+ * between them. The path is a hash so it carries nothing an ad-blocker filter list could match on.
37+ */
38+ export function getTunnelPath ( dsn : DsnLike ) : string | undefined {
39+ const components = makeDsn ( dsn ) ;
40+ return components ? `/${ fnv1a ( dsnToString ( components ) ) . toString ( 36 ) } ` : undefined ;
41+ }
42+
43+ /**
44+ * Returns true if the request targets the managed tunnel.
45+ *
46+ * Without `options`, this uses the managed tunnel config of the active client and matches nothing unless its
47+ * `init` was called with `tunnel: true`.
48+ */
49+ export function isTunnelRequest ( method : string , pathname : string , options ?: TunnelOptions ) : boolean {
50+ if ( method !== 'POST' ) {
51+ return false ;
52+ }
53+
54+ const tunnelOptions = options ?? getClient ( ) ?. getOptions ( ) . _managedTunnel ;
55+ if ( ! tunnelOptions ) {
56+ return false ;
57+ }
58+
59+ const path = stripBasePath ( pathname , tunnelOptions . basePath ) . replace ( / \/ $ / , '' ) ;
60+ return resolveAllowedDsns ( tunnelOptions . allowedDsns ) . some ( dsn => getTunnelPath ( dsn ) === path ) ;
61+ }
62+
63+ /**
64+ * Forwards the request to Sentry if it targets the managed tunnel, otherwise returns `undefined` so the caller
65+ * can hand the request to the app.
66+ */
67+ export function handleTunnelRequestIfMatched ( request : Request , options ?: TunnelOptions ) : Promise < Response > | undefined {
68+ if ( ! isTunnelRequest ( request . method , new URL ( request . url ) . pathname , options ) ) {
69+ return undefined ;
70+ }
71+
72+ const allowedDsns = ( options ?? getClient ( ) ?. getOptions ( ) . _managedTunnel ) ?. allowedDsns ;
73+ return handleTunnelRequest ( { request, allowedDsns } ) ;
1174}
1275
1376/**
@@ -19,7 +82,8 @@ export interface HandleTunnelRequestOptions {
1982 * @returns A `Response` — either the upstream Sentry response on success, or an error response.
2083 */
2184export async function handleTunnelRequest ( options : HandleTunnelRequestOptions ) : Promise < Response > {
22- const { request, allowedDsns } = options ;
85+ const { request } = options ;
86+ const allowedDsns = resolveAllowedDsns ( options . allowedDsns ) ;
2387
2488 if ( allowedDsns . length === 0 ) {
2589 return new Response ( 'Tunnel not configured' , { status : 500 } ) ;
@@ -62,15 +126,48 @@ export async function handleTunnelRequest(options: HandleTunnelRequestOptions):
62126 const sentryIngestUrl = getEnvelopeEndpointWithUrlEncodedAuth ( dsnComponents ) ;
63127
64128 try {
65- return await fetch ( sentryIngestUrl , {
66- method : 'POST' ,
67- headers : {
68- 'Content-Type' : 'application/x-sentry-envelope' ,
69- } ,
70- body,
71- } ) ;
129+ // The forward is SDK plumbing, not app work, so it must not show up as a span.
130+ return await suppressTracing ( ( ) =>
131+ fetch ( sentryIngestUrl , {
132+ method : 'POST' ,
133+ headers : {
134+ 'Content-Type' : 'application/x-sentry-envelope' ,
135+ } ,
136+ body,
137+ } ) ,
138+ ) ;
72139 } catch ( error ) {
73140 debug . error ( 'Sentry tunnel: failed to forward envelope' , error ) ;
74141 return new Response ( 'Failed to forward envelope to Sentry' , { status : 500 } ) ;
75142 }
76143}
144+
145+ function resolveAllowedDsns ( allowedDsns : Array < string > | undefined ) : Array < string > {
146+ if ( allowedDsns ) {
147+ return allowedDsns ;
148+ }
149+
150+ const dsn = getClient ( ) ?. getDsn ( ) ;
151+ return dsn ? [ dsnToString ( dsn ) ] : [ ] ;
152+ }
153+
154+ function stripBasePath ( pathname : string , basePath : string | undefined ) : string {
155+ const base = basePath ?. replace ( / \/ $ / , '' ) ;
156+ if ( ! base ) {
157+ return pathname ;
158+ }
159+
160+ return pathname === base || pathname . startsWith ( `${ base } /` ) ? pathname . slice ( base . length ) : pathname ;
161+ }
162+
163+ /** 32-bit FNV-1a. Synchronous, so the browser can derive the path before its client is constructed. */
164+ function fnv1a ( input : string ) : number {
165+ let hash = 0x811c9dc5 ;
166+ for ( let i = 0 ; i < input . length ; i ++ ) {
167+ // eslint-disable-next-line no-bitwise
168+ hash ^= input . charCodeAt ( i ) ;
169+ hash = Math . imul ( hash , 0x01000193 ) ;
170+ }
171+ // eslint-disable-next-line no-bitwise
172+ return hash >>> 0 ;
173+ }
0 commit comments