22
33import { searchRequestSchema } from '@/features/search/schemas' ;
44import { SearchResponse , SourceRange } from '@/features/search/types' ;
5+ import { SINGLE_TENANT_ORG_ID } from '@/lib/constants' ;
56import { schemaValidationError , serviceErrorResponse } from '@/lib/serviceError' ;
67import { prisma } from '@/prisma' ;
78import type { ProtoGrpcType } from '@/proto/webserver' ;
@@ -12,13 +13,13 @@ import type { StreamSearchResponse__Output } from '@/proto/zoekt/webserver/v1/St
1213import type { WebserverServiceClient } from '@/proto/zoekt/webserver/v1/WebserverService' ;
1314import * as grpc from '@grpc/grpc-js' ;
1415import * as protoLoader from '@grpc/proto-loader' ;
16+ import * as Sentry from '@sentry/nextjs' ;
1517import { PrismaClient , Repo } from '@sourcebot/db' ;
18+ import { parser as _parser } from '@sourcebot/query-language' ;
1619import { createLogger , env } from '@sourcebot/shared' ;
1720import { NextRequest } from 'next/server' ;
1821import * as path from 'path' ;
19- import { parser as _parser } from '@sourcebot/query-language' ;
2022import { transformToZoektQuery } from './transformer' ;
21- import { SINGLE_TENANT_ORG_ID } from '@/lib/constants' ;
2223
2324const logger = createLogger ( 'streamSearchApi' ) ;
2425
@@ -87,8 +88,8 @@ export const POST = async (request: NextRequest) => {
8788 input : query ,
8889 isCaseSensitivityEnabled,
8990 isRegexEnabled,
90- onExpandSearchContext : async ( contextName : string ) => {
91- const context = await prisma . searchContext . findUnique ( {
91+ onExpandSearchContext : async ( contextName : string ) => {
92+ const context = await prisma . searchContext . findUnique ( {
9293 where : {
9394 name_orgId : {
9495 name : contextName ,
@@ -108,8 +109,6 @@ export const POST = async (request: NextRequest) => {
108109 } ,
109110 } ) ;
110111
111- console . log ( JSON . stringify ( zoektQuery , null , 2 ) ) ;
112-
113112 const searchRequest : SearchRequest = {
114113 query : zoektQuery ,
115114 opts : {
@@ -158,9 +157,19 @@ const createSSESearchStream = async (searchRequest: SearchRequest, prisma: Prism
158157 const client = createGrpcClient ( ) ;
159158 let grpcStream : ReturnType < WebserverServiceClient [ 'StreamSearch' ] > | null = null ;
160159 let isStreamActive = true ;
160+ let pendingChunks = 0 ;
161161
162162 return new ReadableStream ( {
163163 async start ( controller ) {
164+ const tryCloseController = ( ) => {
165+ if ( ! isStreamActive && pendingChunks === 0 ) {
166+ controller . enqueue ( new TextEncoder ( ) . encode ( 'data: [DONE]\n\n' ) ) ;
167+ controller . close ( ) ;
168+ client . close ( ) ;
169+ logger . debug ( 'SSE stream closed' ) ;
170+ }
171+ } ;
172+
164173 try {
165174 // @todo : we should just disable tenant enforcement for now.
166175 const metadata = new grpc . Metadata ( ) ;
@@ -190,12 +199,14 @@ const createSSESearchStream = async (searchRequest: SearchRequest, prisma: Prism
190199
191200 // Handle incoming data chunks
192201 grpcStream . on ( 'data' , async ( chunk : StreamSearchResponse__Output ) => {
193- console . log ( 'chunk' ) ;
194-
195202 if ( ! isStreamActive ) {
203+ logger . debug ( 'SSE stream closed, skipping chunk' ) ;
196204 return ;
197205 }
198206
207+ // Track that we're processing a chunk
208+ pendingChunks ++ ;
209+
199210 // grpcStream.on doesn't actually await on our handler, so we need to
200211 // explicitly pause the stream here to prevent the stream from completing
201212 // prior to our asynchronous work being completed.
@@ -352,7 +363,18 @@ const createSSESearchStream = async (searchRequest: SearchRequest, prisma: Prism
352363 } catch ( error ) {
353364 console . error ( 'Error encoding chunk:' , error ) ;
354365 } finally {
366+ pendingChunks -- ;
355367 grpcStream ?. resume ( ) ;
368+
369+ // @note : we were hitting "Controller is already closed" errors when calling
370+ // `controller.enqueue` above for the last chunk. The reasoning was the event
371+ // handler for 'end' was being invoked prior to the completion of the last chunk,
372+ // resulting in the controller being closed prematurely. The workaround was to
373+ // keep track of the number of pending chunks and only close the controller
374+ // when there are no more chunks to process. We need to explicitly call
375+ // `tryCloseController` since there _seems_ to be no ordering guarantees between
376+ // the 'end' event handler and this callback.
377+ tryCloseController ( ) ;
356378 }
357379 } ) ;
358380
@@ -362,17 +384,13 @@ const createSSESearchStream = async (searchRequest: SearchRequest, prisma: Prism
362384 return ;
363385 }
364386 isStreamActive = false ;
365-
366- // Send completion signal
367- controller . enqueue ( new TextEncoder ( ) . encode ( 'data: [DONE]\n\n' ) ) ;
368- controller . close ( ) ;
369- console . log ( 'SSE stream completed' ) ;
370- client . close ( ) ;
387+ tryCloseController ( ) ;
371388 } ) ;
372389
373390 // Handle errors
374391 grpcStream . on ( 'error' , ( error : grpc . ServiceError ) => {
375- console . error ( 'gRPC stream error:' , error ) ;
392+ logger . error ( 'gRPC stream error:' , error ) ;
393+ Sentry . captureException ( error ) ;
376394
377395 if ( ! isStreamActive ) {
378396 return ;
@@ -392,7 +410,7 @@ const createSSESearchStream = async (searchRequest: SearchRequest, prisma: Prism
392410 client . close ( ) ;
393411 } ) ;
394412 } catch ( error ) {
395- console . error ( 'Stream initialization error:' , error ) ;
413+ logger . error ( 'Stream initialization error:' , error ) ;
396414
397415 const errorMessage = error instanceof Error ? error . message : 'Unknown error' ;
398416 const errorData = `data: ${ JSON . stringify ( {
@@ -405,7 +423,7 @@ const createSSESearchStream = async (searchRequest: SearchRequest, prisma: Prism
405423 }
406424 } ,
407425 cancel ( ) {
408- console . log ( 'SSE stream cancelled by client' ) ;
426+ logger . warn ( 'SSE stream cancelled by client' ) ;
409427 isStreamActive = false ;
410428
411429 // Cancel the gRPC stream to stop receiving data
0 commit comments