mirror of
https://github.com/LukeHagar/relay.git
synced 2025-12-07 20:57:45 +00:00
Refactor SvelteKit app to unified webhook relay with WebSocket support
Co-authored-by: lukeslakemail <lukeslakemail@gmail.com>
This commit is contained in:
127
sveltekit-app/src/lib/relay.ts
Normal file
127
sveltekit-app/src/lib/relay.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
63
sveltekit-app/src/routes/api/test-webhook/+server.ts
Normal file
63
sveltekit-app/src/routes/api/test-webhook/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
85
sveltekit-app/src/routes/api/ws/+server.ts
Normal file
85
sveltekit-app/src/routes/api/ws/+server.ts
Normal 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;
|
||||
};
|
||||
152
sveltekit-app/src/routes/webhook/[...path]/+server.ts
Normal file
152
sveltekit-app/src/routes/webhook/[...path]/+server.ts
Normal 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 };
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user