mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-12 12:25:22 +00:00
174 lines
No EOL
6.6 KiB
TypeScript
174 lines
No EOL
6.6 KiB
TypeScript
'use server';
|
|
|
|
import { NextRequest } from 'next/server';
|
|
import * as grpc from '@grpc/grpc-js';
|
|
import * as protoLoader from '@grpc/proto-loader';
|
|
import * as path from 'path';
|
|
import type { ProtoGrpcType } from '@/proto/webserver';
|
|
import type { WebserverServiceClient } from '@/proto/zoekt/webserver/v1/WebserverService';
|
|
import type { SearchRequest } from '@/proto/zoekt/webserver/v1/SearchRequest';
|
|
import type { StreamSearchRequest } from '@/proto/zoekt/webserver/v1/StreamSearchRequest';
|
|
import type { StreamSearchResponse__Output } from '@/proto/zoekt/webserver/v1/StreamSearchResponse';
|
|
import { env } from '@sourcebot/shared';
|
|
import { schemaValidationError, serviceErrorResponse } from '@/lib/serviceError';
|
|
import { searchRequestSchema } from '@/features/search/schemas';
|
|
|
|
/**
|
|
* Create a gRPC client for the Zoekt webserver
|
|
*/
|
|
function createGrpcClient(): WebserverServiceClient {
|
|
// Path to proto files - these should match your monorepo structure
|
|
const protoBasePath = path.join(process.cwd(), '../../vendor/zoekt/grpc/protos');
|
|
const protoPath = path.join(protoBasePath, 'zoekt/webserver/v1/webserver.proto');
|
|
|
|
const packageDefinition = protoLoader.loadSync(protoPath, {
|
|
keepCase: true,
|
|
longs: Number,
|
|
enums: String,
|
|
defaults: true,
|
|
oneofs: true,
|
|
includeDirs: [protoBasePath],
|
|
});
|
|
|
|
const proto = grpc.loadPackageDefinition(packageDefinition) as unknown as ProtoGrpcType;
|
|
|
|
// Extract host and port from ZOEKT_WEBSERVER_URL
|
|
const zoektUrl = new URL(env.ZOEKT_WEBSERVER_URL);
|
|
const grpcAddress = `${zoektUrl.hostname}:${zoektUrl.port}`;
|
|
|
|
return new proto.zoekt.webserver.v1.WebserverService(
|
|
grpcAddress,
|
|
grpc.credentials.createInsecure(),
|
|
{
|
|
'grpc.max_receive_message_length': 500 * 1024 * 1024, // 500MB
|
|
'grpc.max_send_message_length': 500 * 1024 * 1024, // 500MB
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* POST handler for streaming search via SSE
|
|
*/
|
|
export const POST = async (request: NextRequest) => {
|
|
try {
|
|
// Parse and validate request body
|
|
const body = await request.json();
|
|
const parsed = await searchRequestSchema.safeParseAsync(body);
|
|
|
|
if (!parsed.success) {
|
|
return serviceErrorResponse(schemaValidationError(parsed.error));
|
|
}
|
|
|
|
const searchRequest: SearchRequest = {
|
|
query: {
|
|
and: {
|
|
children: [
|
|
{
|
|
regexp: {
|
|
regexp: parsed.data.query,
|
|
case_sensitive: true,
|
|
}
|
|
}
|
|
]
|
|
}
|
|
},
|
|
opts: {
|
|
chunk_matches: true,
|
|
num_context_lines: parsed.data.contextLines ?? 5,
|
|
total_max_match_count: parsed.data.matches,
|
|
},
|
|
};
|
|
|
|
// Create ReadableStream for SSE
|
|
const stream = new ReadableStream({
|
|
async start(controller) {
|
|
const client = createGrpcClient();
|
|
|
|
try {
|
|
const metadata = new grpc.Metadata();
|
|
metadata.add('x-sourcegraph-tenant-id', '1');
|
|
|
|
const streamRequest: StreamSearchRequest = {
|
|
request: searchRequest,
|
|
};
|
|
|
|
const grpcStream = client.StreamSearch(streamRequest, metadata);
|
|
|
|
// Handle incoming data chunks
|
|
grpcStream.on('data', (chunk: StreamSearchResponse__Output) => {
|
|
try {
|
|
// SSE format: "data: {json}\n\n"
|
|
const sseData = `data: ${JSON.stringify(chunk)}\n\n`;
|
|
controller.enqueue(new TextEncoder().encode(sseData));
|
|
} catch (error) {
|
|
console.error('Error encoding chunk:', error);
|
|
}
|
|
});
|
|
|
|
// Handle stream completion
|
|
grpcStream.on('end', () => {
|
|
// Send completion signal
|
|
controller.enqueue(new TextEncoder().encode('data: [DONE]\n\n'));
|
|
controller.close();
|
|
client.close();
|
|
});
|
|
|
|
// Handle errors
|
|
grpcStream.on('error', (error: grpc.ServiceError) => {
|
|
console.error('gRPC stream error:', error);
|
|
|
|
// Send error as SSE event
|
|
const errorData = `data: ${JSON.stringify({
|
|
error: {
|
|
code: error.code,
|
|
message: error.details || error.message,
|
|
}
|
|
})}\n\n`;
|
|
controller.enqueue(new TextEncoder().encode(errorData));
|
|
|
|
controller.close();
|
|
client.close();
|
|
});
|
|
} catch (error) {
|
|
console.error('Stream initialization error:', error);
|
|
|
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
const errorData = `data: ${JSON.stringify({
|
|
error: { message: errorMessage }
|
|
})}\n\n`;
|
|
controller.enqueue(new TextEncoder().encode(errorData));
|
|
|
|
controller.close();
|
|
client.close();
|
|
}
|
|
},
|
|
cancel() {
|
|
// Cleanup when client cancels the stream
|
|
console.log('SSE stream cancelled by client');
|
|
}
|
|
});
|
|
|
|
// Return streaming response with SSE headers
|
|
return new Response(stream, {
|
|
headers: {
|
|
'Content-Type': 'text/event-stream',
|
|
'Cache-Control': 'no-cache, no-transform',
|
|
'Connection': 'keep-alive',
|
|
'X-Accel-Buffering': 'no', // Disable nginx buffering if applicable
|
|
},
|
|
});
|
|
} catch (error) {
|
|
console.error('Request handling error:', error);
|
|
return new Response(
|
|
JSON.stringify({
|
|
error: {
|
|
message: error instanceof Error ? error.message : 'Internal server error'
|
|
}
|
|
}),
|
|
{
|
|
status: 500,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
}
|
|
);
|
|
}
|
|
}; |