11/**
22 * sentry local
33 *
4- * Run a local Spotlight-compatible server.
4+ * Run a local Spotlight-compatible server, or attach to one already running .
55 *
6- * Spotlight (https://spotlightjs.com/) is "Sentry for Development" — a small
7- * local proxy that ingests Sentry envelopes from SDKs running in your dev
8- * stack and surfaces them in real time.
9- *
10- * This command starts a minimal Hono HTTP server that:
11- *
12- * 1. Accepts envelopes from Sentry SDKs at the standard endpoints:
13- * - `POST /stream` (Spotlight-compatible)
14- * - `POST /api/{projectId}/envelope/` (Sentry SDK ingest path)
15- * 2. Pushes them into the buffer provided by `@spotlightjs/spotlight/sdk`,
16- * which lazily parses each envelope.
17- * 3. Streams new envelopes back to subscribers via Server-Sent Events at
18- * `GET /stream` — compatible with the Spotlight overlay/UI.
19- * 4. Tails events to the terminal as they arrive so you can see what your
20- * app is sending without leaving the CLI.
6+ * On startup the command probes `http://<host>:<port>/health`. If a server
7+ * is already listening (e.g. a Spotlight sidecar or another `sentry local`),
8+ * the command attaches as an SSE consumer and tails events from it. Otherwise
9+ * it starts its own Hono HTTP server.
2110 *
2211 * Learn more: https://spotlightjs.com/docs/getting-started/
2312 *
@@ -57,6 +46,9 @@ const BUFFER_SIZE = 500;
5746/** Canonical content type for Sentry envelopes. */
5847const SENTRY_CONTENT_TYPE = "application/x-sentry-envelope" ;
5948
49+ /** Trailing carriage return — stripped from SSE lines. */
50+ const CR_RE = / \r $ / ;
51+
6052/** Maximum ingest body size (10 MB). Rejects oversized payloads early. */
6153const MAX_BODY_BYTES = 10 * 1024 * 1024 ;
6254
@@ -619,24 +611,23 @@ function waitForShutdown(server: Server): Promise<void> {
619611 } ) ;
620612}
621613
622- /** Maximum number of consecutive ports to try before giving up. */
623- const MAX_PORT_ATTEMPTS = 10 ;
614+ /** Maximum retries on EADDRINUSE before giving up. */
615+ const MAX_PORT_RETRIES = 3 ;
616+
617+ /** Delay between EADDRINUSE retries in milliseconds. */
618+ const PORT_RETRY_DELAY_MS = 5000 ;
624619
625620/**
626- * Try to start the HTTP server, auto-incrementing the port on EADDRINUSE.
621+ * Try to start the HTTP server, retrying with backoff on EADDRINUSE.
627622 *
628- * `@hono/node-server`'s `serve()` calls `server.listen()` synchronously and
629- * returns immediately — the actual bind happens asynchronously. We wrap it in
630- * a Promise that resolves on the `listening` event and rejects on `error`.
631- * When the port is busy we bump the port number and retry up to
632- * {@link MAX_PORT_ATTEMPTS} times, warning the user on each bump.
623+ * Retries up to {@link MAX_PORT_RETRIES} times with a {@link PORT_RETRY_DELAY_MS}
624+ * delay between attempts, matching Spotlight's retry strategy.
633625 */
634626function tryListen (
635627 app : Hono ,
636- startPort : number ,
628+ port : number ,
637629 hostname : string
638630) : Promise < { server : Server ; port : number } > {
639- let port = startPort ;
640631 let attempts = 0 ;
641632
642633 const attempt = ( ) : Promise < { server : Server ; port : number } > =>
@@ -648,20 +639,22 @@ function tryListen(
648639 } ) as unknown as Server ;
649640
650641 server . once ( "listening" , ( ) => resolve ( { server, port } ) ) ;
651- server . once ( "error" , ( err : NodeJS . ErrnoException ) => {
642+ server . once ( "error" , async ( err : NodeJS . ErrnoException ) => {
652643 if ( err . code === "EADDRINUSE" ) {
653644 attempts += 1 ;
654- if ( attempts >= MAX_PORT_ATTEMPTS ) {
645+ if ( attempts > MAX_PORT_RETRIES ) {
655646 reject (
656647 new ValidationError (
657- `Port ${ startPort } is in use and no open port found after ${ MAX_PORT_ATTEMPTS } attempts ` ,
648+ `Port ${ port } is in use after ${ MAX_PORT_RETRIES } retries ` ,
658649 "port"
659650 )
660651 ) ;
661652 return ;
662653 }
663- logger . warn ( `Port ${ port } is in use, trying ${ port + 1 } ...` ) ;
664- port += 1 ;
654+ logger . warn (
655+ `Port ${ port } is in use, retrying in ${ PORT_RETRY_DELAY_MS / 1000 } s (attempt ${ attempts } /${ MAX_PORT_RETRIES } )...`
656+ ) ;
657+ await Bun . sleep ( PORT_RETRY_DELAY_MS ) ;
665658 resolve ( attempt ( ) ) ;
666659 return ;
667660 }
@@ -672,22 +665,120 @@ function tryListen(
672665 return attempt ( ) ;
673666}
674667
668+ /**
669+ * Check whether a Spotlight server is already running on the given URL.
670+ * Returns `true` if the health endpoint responds successfully.
671+ */
672+ async function isServerRunning ( url : string ) : Promise < boolean > {
673+ try {
674+ const res = await fetch ( `${ url } /health` ) ;
675+ return res . ok ;
676+ } catch {
677+ return false ;
678+ }
679+ }
680+
681+ /** Mutable state for the SSE line parser. */
682+ type SSEParserState = {
683+ eventType : string ;
684+ dataLines : string [ ] ;
685+ } ;
686+
687+ /** Process a single SSE line, dispatching complete events via callback. */
688+ function feedSSELine (
689+ line : string ,
690+ state : SSEParserState ,
691+ onEvent : ( type : string , data : string ) => void
692+ ) : void {
693+ if ( line . startsWith ( "event:" ) ) {
694+ state . eventType = line . slice ( 6 ) . trim ( ) ;
695+ } else if ( line . startsWith ( "data:" ) ) {
696+ state . dataLines . push ( line . slice ( 5 ) . trimStart ( ) ) ;
697+ } else if ( line === "" && state . dataLines . length > 0 ) {
698+ onEvent ( state . eventType , state . dataLines . join ( "\n" ) ) ;
699+ state . eventType = "" ;
700+ state . dataLines = [ ] ;
701+ }
702+ }
703+
704+ /**
705+ * Consume SSE events from an upstream Spotlight server and print them.
706+ *
707+ * Bun doesn't have a global `EventSource`, so we use `fetch` with a
708+ * streaming body and parse the SSE wire format manually.
709+ */
710+ async function consumeSSE (
711+ url : string ,
712+ activeFilters : ReadonlySet < FilterValue > ,
713+ signal : AbortSignal
714+ ) : Promise < void > {
715+ const res = await fetch ( `${ url } /stream` , {
716+ headers : { Accept : "text/event-stream" } ,
717+ signal,
718+ } ) ;
719+ if ( ! res . body ) {
720+ return ;
721+ }
722+
723+ const decoder = new TextDecoder ( ) ;
724+ const state : SSEParserState = { eventType : "" , dataLines : [ ] } ;
725+
726+ for await ( const chunk of res . body ) {
727+ const text = decoder . decode ( chunk as Uint8Array , { stream : true } ) ;
728+ for ( const rawLine of text . split ( "\n" ) ) {
729+ feedSSELine ( rawLine . replace ( CR_RE , "" ) , state , ( type , data ) => {
730+ if ( type === SENTRY_CONTENT_TYPE ) {
731+ processSSEEvent ( data , activeFilters ) ;
732+ }
733+ } ) ;
734+ }
735+ }
736+ }
737+
738+ /** Parse and format a single SSE data payload from upstream. */
739+ function processSSEEvent (
740+ data : string ,
741+ activeFilters : ReadonlySet < FilterValue >
742+ ) : void {
743+ try {
744+ const envelope = JSON . parse ( data ) as [
745+ Record < string , unknown > ,
746+ [ { type ?: string } , unknown ] [ ] ,
747+ ] ;
748+ const [ header , items ] = envelope ;
749+ for ( const [ itemHeader , itemPayload ] of items ) {
750+ if ( ! isItemIncluded ( itemHeader . type , activeFilters ) ) {
751+ continue ;
752+ }
753+ for ( const line of formatItem (
754+ itemHeader . type ,
755+ itemPayload as Record < string , unknown > ,
756+ header ,
757+ itemHeader . type ?? "envelope"
758+ ) ) {
759+ logger . log ( line ) ;
760+ }
761+ }
762+ } catch ( err ) {
763+ logger . debug (
764+ `Failed to parse SSE event: ${ err instanceof Error ? err . message : String ( err ) } `
765+ ) ;
766+ }
767+ }
768+
675769export const localCommand = buildCommand ( {
676770 docs : {
677771 brief : "Run a local Spotlight server to capture dev SDK events" ,
678772 fullDescription :
679- "Start a local Spotlight-compatible server.\n\n" +
773+ "Start a local Spotlight-compatible server, or attach to one\n" +
774+ "already running on the same port.\n\n" +
680775 "Spotlight is Sentry for Development — it gives you a live view of\n" +
681- "errors, traces, and logs emitted by Sentry SDKs in your dev stack.\n" +
682- "This command runs a minimal Hono server that ingests envelopes\n" +
683- "from any Sentry SDK and tails them to your terminal.\n\n" +
684- "Endpoints:\n" +
685- " POST /stream — Spotlight ingest\n" +
686- " POST /api/{projectId}/envelope/ — Sentry SDK ingest\n" +
687- " GET /stream — SSE feed (for the Spotlight overlay)\n" +
688- " GET /health — health check\n\n" +
776+ "errors, traces, and logs emitted by Sentry SDKs in your dev stack.\n\n" +
777+ "If a server is already listening on the port, the command connects\n" +
778+ "as an SSE consumer and tails events from it. Otherwise it starts\n" +
779+ "its own server.\n\n" +
689780 "Learn more: https://spotlightjs.com/docs/getting-started/\n\n" +
690- "Press Ctrl-C to stop the server ." ,
781+ "Press Ctrl-C to stop." ,
691782 } ,
692783 parameters : {
693784 flags : {
@@ -726,8 +817,39 @@ export const localCommand = buildCommand({
726817 } ,
727818 auth : false ,
728819 async * func ( this : SentryContext , flags : LocalFlags ) {
729- const buffer = createSpotlightBuffer ( BUFFER_SIZE ) ;
730820 const activeFilters = new Set ( flags . filter ) ;
821+ const url = `http://${ flags . host } :${ flags . port } ` ;
822+
823+ if ( await isServerRunning ( url ) ) {
824+ logger . info ( `Connected to existing server at ${ bold ( url ) } ` ) ;
825+ if ( activeFilters . size > 0 ) {
826+ logger . info ( `Filtering: ${ [ ...activeFilters ] . join ( ", " ) } ` ) ;
827+ }
828+ logger . info ( "Press Ctrl-C to stop." ) ;
829+
830+ const ac = new AbortController ( ) ;
831+ const stop = ( ) => ac . abort ( ) ;
832+ process . on ( "SIGINT" , stop ) ;
833+ process . on ( "SIGTERM" , stop ) ;
834+
835+ if ( flags . quiet ) {
836+ await new Promise < void > ( ( resolve ) => {
837+ ac . signal . addEventListener ( "abort" , ( ) => resolve ( ) ) ;
838+ } ) ;
839+ } else {
840+ await consumeSSE ( url , activeFilters , ac . signal ) . catch (
841+ ( err : unknown ) => {
842+ if ( ! ( err instanceof DOMException && err . name === "AbortError" ) ) {
843+ throw err ;
844+ }
845+ }
846+ ) ;
847+ }
848+ logger . log ( "Disconnected." ) ;
849+ return ;
850+ }
851+
852+ const buffer = createSpotlightBuffer ( BUFFER_SIZE ) ;
731853
732854 if ( ! flags . quiet ) {
733855 buffer . subscribe ( ( container ) => {
@@ -745,8 +867,8 @@ export const localCommand = buildCommand({
745867 flags . host
746868 ) ;
747869
748- const url = `http://${ flags . host } :${ boundPort } ` ;
749- logger . info ( `Listening on ${ bold ( url ) } ` ) ;
870+ const listenUrl = `http://${ flags . host } :${ boundPort } ` ;
871+ logger . info ( `Listening on ${ bold ( listenUrl ) } ` ) ;
750872 if ( activeFilters . size > 0 ) {
751873 logger . info ( `Filtering: ${ [ ...activeFilters ] . join ( ", " ) } ` ) ;
752874 }
0 commit comments