Refactor SvelteKit app to unified webhook relay with WebSocket support

Co-authored-by: lukeslakemail <lukeslakemail@gmail.com>
This commit is contained in:
Cursor Agent
2025-08-30 03:45:30 +00:00
parent 7687fde990
commit b407466256
11 changed files with 602 additions and 102 deletions

View File

@@ -0,0 +1,127 @@
import { prisma } from '$lib/db';
export interface RelayResult {
success: boolean;
target: string;
statusCode?: number;
error?: string;
responseTime?: number;
}
export async function relayWebhookToTargets(
userId: string,
webhookData: {
method: string;
path: string;
query: string;
body: any;
headers: Record<string, string>;
}
): Promise<RelayResult[]> {
try {
// Get all active relay targets for the user
const targets = await prisma.relayTarget.findMany({
where: {
userId,
active: true
}
});
if (targets.length === 0) {
return [];
}
// Forward webhook to all active targets
const results = await Promise.allSettled(
targets.map(async (target) => {
const startTime = Date.now();
try {
// Prepare headers for forwarding
const forwardHeaders: Record<string, string> = {
'Content-Type': 'application/json',
'User-Agent': 'WebhookRelay/1.0',
'X-Webhook-Relay-Source': 'webhook-relay',
'X-Webhook-Relay-Target': target.nickname || target.id,
...webhookData.headers
};
// Remove headers that shouldn't be forwarded
delete forwardHeaders['host'];
delete forwardHeaders['authorization'];
delete forwardHeaders['cookie'];
// Forward the webhook
const response = await fetch(target.target, {
method: webhookData.method,
headers: forwardHeaders,
body: webhookData.method !== 'GET' ? JSON.stringify(webhookData.body) : undefined,
signal: AbortSignal.timeout(30000) // 30 second timeout
});
const responseTime = Date.now() - startTime;
return {
success: response.ok,
target: target.target,
statusCode: response.status,
responseTime
} as RelayResult;
} catch (error) {
const responseTime = Date.now() - startTime;
return {
success: false,
target: target.target,
error: error instanceof Error ? error.message : 'Unknown error',
responseTime
} as RelayResult;
}
})
);
// Process results
return results.map((result, index) => {
if (result.status === 'fulfilled') {
return result.value;
} else {
return {
success: false,
target: targets[index]?.target || 'unknown',
error: result.reason?.message || 'Unknown error'
} as RelayResult;
}
});
} catch (error) {
console.error('Error relaying webhook to targets:', error);
return [];
}
}
// Optional: Store relay results for analytics
export async function storeRelayResults(
webhookEventId: string,
results: RelayResult[]
): Promise<void> {
try {
// You could create a new table for relay results if needed
// For now, we'll just log them
console.log('Relay results for webhook:', webhookEventId, results);
// Example of storing results (if you add a RelayResult table):
// await prisma.relayResult.createMany({
// data: results.map(result => ({
// webhookEventId,
// target: result.target,
// success: result.success,
// statusCode: result.statusCode,
// error: result.error,
// responseTime: result.responseTime
// }))
// });
} catch (error) {
console.error('Error storing relay results:', error);
}
}

View File

@@ -136,6 +136,9 @@ class WebSocketClient {
}
}
export function createWebSocketClient(url: string) {
return new WebSocketClient(url);
export function createWebSocketClient() {
// Use the SvelteKit WebSocket endpoint
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.host;
return new WebSocketClient(`${protocol}//${host}/api/ws`);
}

View File

@@ -17,8 +17,8 @@
onMount(() => {
if (data.session?.user) {
// Connect to WebSocket for real-time updates
wsClient = createWebSocketClient(`ws://localhost:4200/api/relay`);
// Connect to SvelteKit WebSocket endpoint
wsClient = createWebSocketClient();
wsClient.events.subscribe((newEvents) => {
events = newEvents;

View File

@@ -0,0 +1,63 @@
import { json } from '@sveltejs/kit';
import { prisma } from '$lib/db';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ request, locals }) => {
const session = await locals.getSession();
if (!session?.user?.id) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
try {
const { subdomain, method = 'POST', path = '/test', body = { test: true } } = await request.json();
if (!subdomain) {
return json({ error: 'Subdomain is required' }, { status: 400 });
}
// Verify the subdomain belongs to the user
const user = await prisma.user.findUnique({
where: {
id: session.user.id,
subdomain: subdomain
}
});
if (!user) {
return json({ error: 'Invalid subdomain for user' }, { status: 400 });
}
// Create a test webhook event
const webhookEvent = {
userId: user.id,
method: method,
path: path,
query: '',
body: JSON.stringify(body),
headers: JSON.stringify({
'Content-Type': 'application/json',
'User-Agent': 'Test-Webhook/1.0',
'X-Test-Webhook': 'true'
}),
createdAt: new Date(),
};
// Store in database
const storedEvent = await prisma.webhookEvent.create({
data: webhookEvent,
});
return json({
success: true,
message: 'Test webhook created successfully',
eventId: storedEvent.id,
webhookUrl: `https://${subdomain}.yourdomain.com${path}`,
timestamp: storedEvent.createdAt
});
} catch (error) {
console.error('Error creating test webhook:', error);
return json({ error: 'Failed to create test webhook' }, { status: 500 });
}
};

View File

@@ -0,0 +1,85 @@
import { prisma } from '$lib/db';
import { clients } from '../../webhook/[...path]/+server.js';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ request, locals }) => {
const session = await locals.getSession();
if (!session?.user?.id) {
return new Response('Unauthorized', { status: 401 });
}
// Get user data
const user = await prisma.user.findUnique({
where: { id: session.user.id },
});
if (!user) {
return new Response('Unauthorized', { status: 401 });
}
// Upgrade to WebSocket
const upgrade = request.headers.get('upgrade');
if (upgrade !== 'websocket') {
return new Response('Expected websocket', { status: 426 });
}
const { socket, response } = Deno.upgradeWebSocket(request);
// Add client to the map
if (!clients.has(user.subdomain)) {
clients.set(user.subdomain, []);
}
clients.get(user.subdomain)!.push(socket);
// Send welcome message
socket.send(JSON.stringify({
message: `Connected to WebSocket server as ${user.name} with subdomain ${user.subdomain}`,
type: 'connection'
}));
// Handle WebSocket events
socket.onopen = () => {
console.log(`WebSocket connected for user: ${user.subdomain}`);
};
socket.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
console.log('Received message:', data);
// Echo back for testing
socket.send(JSON.stringify({
message: 'Message received',
type: 'echo',
data: data
}));
} catch (error) {
console.error('Error parsing WebSocket message:', error);
}
};
socket.onclose = () => {
console.log(`WebSocket disconnected for user: ${user.subdomain}`);
// Remove client from the map
const userClients = clients.get(user.subdomain);
if (userClients) {
const index = userClients.indexOf(socket);
if (index > -1) {
userClients.splice(index, 1);
}
// Remove empty arrays
if (userClients.length === 0) {
clients.delete(user.subdomain);
}
}
};
socket.onerror = (error) => {
console.error(`WebSocket error for user ${user.subdomain}:`, error);
};
return response;
};

View File

@@ -0,0 +1,152 @@
import { json } from '@sveltejs/kit';
import { prisma } from '$lib/db';
import { relayWebhookToTargets, storeRelayResults } from '$lib/relay';
import type { RequestHandler } from './$types';
// Store connected WebSocket clients for real-time updates
const clients: Map<string, any[]> = new Map();
export const GET: RequestHandler = async ({ request, params, url }) => {
return handleWebhook(request, params, url);
};
export const POST: RequestHandler = async ({ request, params, url }) => {
return handleWebhook(request, params, url);
};
export const PUT: RequestHandler = async ({ request, params, url }) => {
return handleWebhook(request, params, url);
};
export const DELETE: RequestHandler = async ({ request, params, url }) => {
return handleWebhook(request, params, url);
};
export const PATCH: RequestHandler = async ({ request, params, url }) => {
return handleWebhook(request, params, url);
};
async function handleWebhook(request: Request, params: any, url: URL) {
try {
// Extract subdomain from hostname
const hostname = request.headers.get('host') || '';
const urlParts = hostname.split('.');
let subdomain = '';
if (urlParts.length > 1) {
subdomain = urlParts[0];
}
if (!subdomain) {
return json({ error: 'Missing Subdomain' }, { status: 400 });
}
// Find user by subdomain
const user = await prisma.user.findUnique({
where: { subdomain },
});
if (!user) {
return json({ error: 'Invalid Subdomain' }, { status: 404 });
}
// Get request body
let body: any = null;
const contentType = request.headers.get('content-type');
if (contentType?.includes('application/json')) {
try {
body = await request.json();
} catch {
body = null;
}
} else if (contentType?.includes('application/x-www-form-urlencoded')) {
const formData = await request.formData();
body = Object.fromEntries(formData);
} else {
// Try to get raw body as text
try {
body = await request.text();
} catch {
body = null;
}
}
// Get headers (excluding sensitive ones)
const headers: Record<string, string> = {};
for (const [key, value] of request.headers.entries()) {
if (!['authorization', 'cookie', 'x-forwarded-for'].includes(key.toLowerCase())) {
headers[key] = value;
}
}
// Build webhook event
const webhookEvent = {
userId: user.id,
method: request.method,
path: url.pathname,
query: url.search,
body: JSON.stringify(body),
headers: JSON.stringify(headers),
createdAt: new Date(),
};
// Store in database
const storedEvent = await prisma.webhookEvent.create({
data: webhookEvent,
});
// Relay to configured targets (async, don't wait for completion)
const relayResults = await relayWebhookToTargets(user.id, {
method: webhookData.method,
path: webhookData.path,
query: webhookData.query,
body: body,
headers: headers
});
// Store relay results for analytics
storeRelayResults(storedEvent.id, relayResults);
// Broadcast to WebSocket clients
let messageSent = false;
if (clients.has(subdomain)) {
try {
const userClients = clients.get(subdomain) || [];
userClients.forEach((client) => {
if (client.readyState === 1) { // WebSocket.OPEN
client.send(JSON.stringify({
...storedEvent,
user: {
name: user.name,
subdomain: user.subdomain
},
relayResults: relayResults
}));
}
});
messageSent = true;
} catch (error) {
console.error('Error broadcasting to WebSocket clients:', error);
messageSent = false;
}
}
// Return success response
return json({
success: true,
logged: true,
forwarded: messageSent,
subdomain,
eventId: storedEvent.id,
timestamp: storedEvent.createdAt
}, { status: 200 });
} catch (error) {
console.error('Error handling webhook:', error);
return json({ error: 'Internal Server Error' }, { status: 500 });
}
}
// Export the clients map for WebSocket handler
export { clients };

View File

@@ -18,7 +18,7 @@
onMount(() => {
if (data.session?.user) {
wsClient = createWebSocketClient(`ws://localhost:4200/api/relay`);
wsClient = createWebSocketClient();
wsClient.events.subscribe((newEvents) => {
// Merge new events with existing ones