mirror of
https://github.com/LukeHagar/relay.git
synced 2025-12-09 20:57:45 +00:00
Implement WebSocket-based webhook relay with enhanced SvelteKit integration
Co-authored-by: lukeslakemail <lukeslakemail@gmail.com>
This commit is contained in:
@@ -1,10 +1,21 @@
|
||||
# Database
|
||||
DATABASE_URL="postgresql://username:password@localhost:5432/webhook_relay"
|
||||
# Database Configuration
|
||||
DATABASE_URL="postgresql://username:password@localhost:5432/webhook_relay_sveltekit"
|
||||
|
||||
# Auth.js
|
||||
AUTH_SECRET="your-auth-secret-here"
|
||||
GITHUB_CLIENT_ID="your-github-client-id"
|
||||
GITHUB_CLIENT_SECRET="your-github-client-secret"
|
||||
# Authentication (Auth.js)
|
||||
AUTH_SECRET="your-super-secret-auth-key-here-min-32-chars"
|
||||
GITHUB_CLIENT_ID="your-github-oauth-app-client-id"
|
||||
GITHUB_CLIENT_SECRET="your-github-oauth-app-client-secret"
|
||||
|
||||
# Optional: Redirect URL after authentication
|
||||
REDIRECT_URL="http://localhost:5173/dashboard"
|
||||
# Application Configuration
|
||||
REDIRECT_URL="http://localhost:5173/dashboard"
|
||||
|
||||
# WebSocket Server Configuration
|
||||
WS_PORT="4001"
|
||||
|
||||
# Optional: Custom domain configuration for production
|
||||
# PUBLIC_DOMAIN="yourdomain.com"
|
||||
# PUBLIC_WS_DOMAIN="ws.yourdomain.com"
|
||||
|
||||
# Development Settings
|
||||
# NODE_ENV="development"
|
||||
# LOG_LEVEL="debug"
|
||||
@@ -5,6 +5,8 @@
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"dev": "vite dev",
|
||||
"dev:full": "node scripts/dev.js",
|
||||
"dev:ws": "node scripts/websocket-server.js",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
@@ -16,6 +18,7 @@
|
||||
"@sveltejs/adapter-auto": "^3.0.0",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
"@types/ws": "^8.5.10",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.32",
|
||||
"prisma": "^5.21.1",
|
||||
@@ -31,6 +34,7 @@
|
||||
"@auth/sveltekit": "^1.4.2",
|
||||
"@prisma/client": "^5.21.1",
|
||||
"lucide-svelte": "^0.447.0",
|
||||
"ws": "^8.18.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"type": "module"
|
||||
|
||||
42
sveltekit-integration/scripts/dev.js
Normal file
42
sveltekit-integration/scripts/dev.js
Normal file
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const projectRoot = join(__dirname, '..');
|
||||
|
||||
// Start SvelteKit dev server
|
||||
const svelteProcess = spawn('npm', ['run', 'dev'], {
|
||||
cwd: projectRoot,
|
||||
stdio: 'inherit',
|
||||
shell: true
|
||||
});
|
||||
|
||||
// Start WebSocket server
|
||||
const wsProcess = spawn('node', ['scripts/websocket-server.js'], {
|
||||
cwd: projectRoot,
|
||||
stdio: 'inherit',
|
||||
shell: true
|
||||
});
|
||||
|
||||
// Handle process cleanup
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\nShutting down servers...');
|
||||
svelteProcess.kill('SIGINT');
|
||||
wsProcess.kill('SIGINT');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
svelteProcess.kill('SIGTERM');
|
||||
wsProcess.kill('SIGTERM');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
console.log('Starting SvelteKit development environment...');
|
||||
console.log('SvelteKit: http://localhost:5173');
|
||||
console.log('WebSocket: ws://localhost:4001');
|
||||
console.log('Press Ctrl+C to stop all servers');
|
||||
129
sveltekit-integration/scripts/websocket-server.js
Normal file
129
sveltekit-integration/scripts/websocket-server.js
Normal file
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Standalone WebSocket server for development
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { createServer } from 'http';
|
||||
import { parse } from 'url';
|
||||
|
||||
// Simple in-memory connection storage
|
||||
const connections = new Map();
|
||||
|
||||
// Create HTTP server for WebSocket upgrade
|
||||
const server = createServer();
|
||||
|
||||
const wss = new WebSocketServer({
|
||||
server,
|
||||
verifyClient: (info) => {
|
||||
// In development, allow all connections
|
||||
// In production, implement proper token verification
|
||||
const url = parse(info.req.url, true);
|
||||
const token = url.query.token;
|
||||
|
||||
if (!token) {
|
||||
console.log('WebSocket connection rejected: No token');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Store token for connection (simplified for dev)
|
||||
info.req.token = token;
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
wss.on('connection', (ws, req) => {
|
||||
const token = req.token;
|
||||
const userId = `user-${token.slice(-8)}`; // Simplified user ID
|
||||
|
||||
console.log(`WebSocket connected for user ${userId}`);
|
||||
|
||||
// Store connection
|
||||
if (!connections.has(userId)) {
|
||||
connections.set(userId, new Set());
|
||||
}
|
||||
connections.get(userId).add(ws);
|
||||
|
||||
// Send welcome message
|
||||
ws.send(JSON.stringify({
|
||||
id: Date.now().toString(),
|
||||
type: 'system',
|
||||
data: {
|
||||
message: 'Connected to webhook relay',
|
||||
timestamp: new Date().toISOString(),
|
||||
userId
|
||||
}
|
||||
}));
|
||||
|
||||
// Handle messages
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
console.log(`Message from ${userId}:`, message.type);
|
||||
|
||||
// Handle ping/pong
|
||||
if (message.type === 'ping') {
|
||||
ws.send(JSON.stringify({
|
||||
id: Date.now().toString(),
|
||||
type: 'pong',
|
||||
data: { timestamp: new Date().toISOString() }
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse message:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle close
|
||||
ws.on('close', () => {
|
||||
console.log(`WebSocket disconnected for user ${userId}`);
|
||||
const userConnections = connections.get(userId);
|
||||
if (userConnections) {
|
||||
userConnections.delete(ws);
|
||||
if (userConnections.size === 0) {
|
||||
connections.delete(userId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle errors
|
||||
ws.on('error', (error) => {
|
||||
console.error(`WebSocket error for user ${userId}:`, error);
|
||||
});
|
||||
});
|
||||
|
||||
// Broadcast function for testing
|
||||
global.broadcastToUser = (userId, event) => {
|
||||
const userConnections = connections.get(userId);
|
||||
if (userConnections) {
|
||||
userConnections.forEach(ws => {
|
||||
if (ws.readyState === 1) { // OPEN
|
||||
ws.send(JSON.stringify(event));
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const port = process.env.WS_PORT || 4001;
|
||||
server.listen(port, () => {
|
||||
console.log(`WebSocket server listening on port ${port}`);
|
||||
console.log(`WebSocket URL: ws://localhost:${port}`);
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\nShutting down WebSocket server...');
|
||||
wss.close(() => {
|
||||
server.close(() => {
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
wss.close(() => {
|
||||
server.close(() => {
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
17
sveltekit-integration/src/app.ts
Normal file
17
sveltekit-integration/src/app.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// Initialize WebSocket server when the app starts
|
||||
import { initWebSocketServer } from '$lib/server/websocket-server';
|
||||
|
||||
// Initialize WebSocket server in server environment
|
||||
if (typeof window === 'undefined') {
|
||||
const wsPort = parseInt(process.env.WS_PORT || '4001');
|
||||
|
||||
// Delay initialization to ensure everything is ready
|
||||
setTimeout(() => {
|
||||
try {
|
||||
initWebSocketServer(wsPort);
|
||||
console.log(`WebSocket server started on port ${wsPort}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to start WebSocket server:', error);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<script lang="ts">
|
||||
import { connectionStatus, webhookStore } from '$lib/stores/webhooks';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let reconnectAttempts = 0;
|
||||
const maxReconnectAttempts = 5;
|
||||
|
||||
onMount(() => {
|
||||
// Auto-connect when component mounts
|
||||
webhookStore.connect();
|
||||
});
|
||||
|
||||
function handleReconnect() {
|
||||
if (reconnectAttempts < maxReconnectAttempts) {
|
||||
reconnectAttempts++;
|
||||
webhookStore.connect();
|
||||
}
|
||||
}
|
||||
|
||||
$: if ($connectionStatus === 'connected') {
|
||||
reconnectAttempts = 0; // Reset on successful connection
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<!-- Status Indicator -->
|
||||
<div class="flex items-center">
|
||||
{#if $connectionStatus === 'connected'}
|
||||
<div class="w-3 h-3 bg-green-500 rounded-full animate-pulse"></div>
|
||||
{:else if $connectionStatus === 'connecting'}
|
||||
<div class="w-3 h-3 bg-yellow-500 rounded-full animate-spin"></div>
|
||||
{:else}
|
||||
<div class="w-3 h-3 bg-red-500 rounded-full"></div>
|
||||
{/if}
|
||||
<span class="ml-2 text-sm font-medium text-gray-700 capitalize">
|
||||
{$connectionStatus}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Reconnect Button (only show when disconnected) -->
|
||||
{#if $connectionStatus === 'disconnected' && reconnectAttempts < maxReconnectAttempts}
|
||||
<button
|
||||
on:click={handleReconnect}
|
||||
class="text-xs text-blue-600 hover:text-blue-500 underline"
|
||||
>
|
||||
Reconnect
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- WebSocket Info -->
|
||||
{#if $connectionStatus === 'connected'}
|
||||
<span class="text-xs text-gray-500">
|
||||
WebSocket Active
|
||||
</span>
|
||||
{:else if $connectionStatus === 'disconnected'}
|
||||
<span class="text-xs text-red-500">
|
||||
Real-time updates unavailable
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,82 +1,16 @@
|
||||
import { prisma } from '$db';
|
||||
|
||||
// Store for Server-Sent Events connections
|
||||
const sseConnections = new Map<string, Set<ReadableStreamDefaultController>>();
|
||||
|
||||
export interface WebhookEvent {
|
||||
id: string;
|
||||
type: 'webhook' | 'system';
|
||||
data: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an SSE connection for a user
|
||||
*/
|
||||
export function addSSEConnection(userId: string, controller: ReadableStreamDefaultController) {
|
||||
if (!sseConnections.has(userId)) {
|
||||
sseConnections.set(userId, new Set());
|
||||
}
|
||||
sseConnections.get(userId)!.add(controller);
|
||||
|
||||
// Send initial connection message
|
||||
sendSSEMessage(controller, {
|
||||
id: crypto.randomUUID(),
|
||||
type: 'system',
|
||||
data: { message: 'Connected to webhook relay', timestamp: new Date().toISOString() }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an SSE connection for a user
|
||||
*/
|
||||
export function removeSSEConnection(userId: string, controller: ReadableStreamDefaultController) {
|
||||
const userConnections = sseConnections.get(userId);
|
||||
if (userConnections) {
|
||||
userConnections.delete(controller);
|
||||
if (userConnections.size === 0) {
|
||||
sseConnections.delete(userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to a specific SSE connection
|
||||
*/
|
||||
function sendSSEMessage(controller: ReadableStreamDefaultController, event: WebhookEvent) {
|
||||
try {
|
||||
const message = `data: ${JSON.stringify(event)}\n\n`;
|
||||
controller.enqueue(new TextEncoder().encode(message));
|
||||
} catch (error) {
|
||||
console.error('Failed to send SSE message:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast event to all connections for a specific user
|
||||
*/
|
||||
export async function broadcastToUser(userId: string, event: WebhookEvent): Promise<boolean> {
|
||||
const userConnections = sseConnections.get(userId);
|
||||
|
||||
if (!userConnections || userConnections.size === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let successCount = 0;
|
||||
const totalConnections = userConnections.size;
|
||||
|
||||
userConnections.forEach(controller => {
|
||||
try {
|
||||
sendSSEMessage(controller, event);
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
console.error('Failed to broadcast to connection:', error);
|
||||
// Remove failed connection
|
||||
userConnections.delete(controller);
|
||||
}
|
||||
});
|
||||
|
||||
return successCount > 0;
|
||||
}
|
||||
// Re-export from websocket server for compatibility
|
||||
export {
|
||||
broadcastToUser,
|
||||
getStats as getConnectionStats
|
||||
} from './websocket-server';
|
||||
|
||||
/**
|
||||
* Get recent webhook events for a user
|
||||
|
||||
18
sveltekit-integration/src/lib/server/startup.ts
Normal file
18
sveltekit-integration/src/lib/server/startup.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { initWebSocketServer } from './websocket-server';
|
||||
|
||||
// Initialize WebSocket server when the module loads
|
||||
let wsServerInitialized = false;
|
||||
|
||||
export function ensureWebSocketServer() {
|
||||
if (!wsServerInitialized) {
|
||||
const port = parseInt(process.env.WS_PORT || '4001');
|
||||
initWebSocketServer(port);
|
||||
wsServerInitialized = true;
|
||||
console.log(`WebSocket server initialized on port ${port}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-initialize in server environment
|
||||
if (typeof window === 'undefined') {
|
||||
ensureWebSocketServer();
|
||||
}
|
||||
196
sveltekit-integration/src/lib/server/websocket-manager.ts
Normal file
196
sveltekit-integration/src/lib/server/websocket-manager.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
// WebSocket connection manager for SvelteKit
|
||||
// This provides a simple in-memory WebSocket management system
|
||||
|
||||
export interface WebhookEvent {
|
||||
id: string;
|
||||
type: 'webhook' | 'system' | 'ping' | 'pong';
|
||||
data: any;
|
||||
}
|
||||
|
||||
export interface WSConnection {
|
||||
ws: WebSocket;
|
||||
userId: string;
|
||||
connected: boolean;
|
||||
lastPing?: number;
|
||||
}
|
||||
|
||||
// Store WebSocket connections by user ID
|
||||
const connections = new Map<string, Set<WSConnection>>();
|
||||
|
||||
/**
|
||||
* Add a WebSocket connection for a user
|
||||
*/
|
||||
export function addConnection(userId: string, ws: WebSocket): WSConnection {
|
||||
const connection: WSConnection = {
|
||||
ws,
|
||||
userId,
|
||||
connected: true,
|
||||
lastPing: Date.now()
|
||||
};
|
||||
|
||||
if (!connections.has(userId)) {
|
||||
connections.set(userId, new Set());
|
||||
}
|
||||
|
||||
connections.get(userId)!.add(connection);
|
||||
|
||||
// Set up connection event handlers
|
||||
ws.addEventListener('close', () => {
|
||||
connection.connected = false;
|
||||
removeConnection(userId, connection);
|
||||
});
|
||||
|
||||
ws.addEventListener('error', (error) => {
|
||||
console.error(`WebSocket error for user ${userId}:`, error);
|
||||
connection.connected = false;
|
||||
removeConnection(userId, connection);
|
||||
});
|
||||
|
||||
ws.addEventListener('message', (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data);
|
||||
handleMessage(connection, message);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse WebSocket message:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Send welcome message
|
||||
sendMessage(connection, {
|
||||
id: crypto.randomUUID(),
|
||||
type: 'system',
|
||||
data: {
|
||||
message: 'Connected to webhook relay',
|
||||
timestamp: new Date().toISOString(),
|
||||
userId
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`WebSocket connected for user ${userId}. Total connections: ${connections.get(userId)?.size}`);
|
||||
return connection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a WebSocket connection
|
||||
*/
|
||||
export function removeConnection(userId: string, connection: WSConnection) {
|
||||
const userConnections = connections.get(userId);
|
||||
if (userConnections) {
|
||||
userConnections.delete(connection);
|
||||
if (userConnections.size === 0) {
|
||||
connections.delete(userId);
|
||||
}
|
||||
console.log(`WebSocket disconnected for user ${userId}. Remaining: ${userConnections.size}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming WebSocket messages
|
||||
*/
|
||||
function handleMessage(connection: WSConnection, message: any) {
|
||||
switch (message.type) {
|
||||
case 'ping':
|
||||
connection.lastPing = Date.now();
|
||||
sendMessage(connection, {
|
||||
id: crypto.randomUUID(),
|
||||
type: 'pong',
|
||||
data: { timestamp: new Date().toISOString() }
|
||||
});
|
||||
break;
|
||||
default:
|
||||
console.log(`Received message from user ${connection.userId}:`, message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to a specific connection
|
||||
*/
|
||||
function sendMessage(connection: WSConnection, event: WebhookEvent) {
|
||||
try {
|
||||
if (connection.connected && connection.ws.readyState === WebSocket.OPEN) {
|
||||
connection.ws.send(JSON.stringify(event));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to send WebSocket message:', error);
|
||||
connection.connected = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast event to all connections for a specific user
|
||||
*/
|
||||
export async function broadcastToUser(userId: string, event: WebhookEvent): Promise<boolean> {
|
||||
const userConnections = connections.get(userId);
|
||||
|
||||
if (!userConnections || userConnections.size === 0) {
|
||||
console.log(`No WebSocket connections found for user ${userId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
let successCount = 0;
|
||||
const failedConnections: WSConnection[] = [];
|
||||
|
||||
userConnections.forEach(connection => {
|
||||
try {
|
||||
if (connection.connected && connection.ws.readyState === WebSocket.OPEN) {
|
||||
sendMessage(connection, event);
|
||||
successCount++;
|
||||
} else {
|
||||
failedConnections.push(connection);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to broadcast to connection:', error);
|
||||
failedConnections.push(connection);
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up failed connections
|
||||
failedConnections.forEach(connection => {
|
||||
removeConnection(userId, connection);
|
||||
});
|
||||
|
||||
console.log(`Broadcast to ${successCount}/${userConnections.size} connections for user ${userId}`);
|
||||
return successCount > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection statistics
|
||||
*/
|
||||
export function getConnectionStats() {
|
||||
const stats = {
|
||||
totalUsers: connections.size,
|
||||
totalConnections: 0,
|
||||
userConnections: new Map<string, number>()
|
||||
};
|
||||
|
||||
connections.forEach((userConnections, userId) => {
|
||||
const activeConnections = Array.from(userConnections).filter(c => c.connected).length;
|
||||
stats.totalConnections += activeConnections;
|
||||
stats.userConnections.set(userId, activeConnections);
|
||||
});
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup stale connections (call periodically)
|
||||
*/
|
||||
export function cleanupStaleConnections() {
|
||||
const now = Date.now();
|
||||
const staleThreshold = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
connections.forEach((userConnections, userId) => {
|
||||
const staleConnections: WSConnection[] = [];
|
||||
|
||||
userConnections.forEach(connection => {
|
||||
if (!connection.connected ||
|
||||
(connection.lastPing && now - connection.lastPing > staleThreshold)) {
|
||||
staleConnections.push(connection);
|
||||
}
|
||||
});
|
||||
|
||||
staleConnections.forEach(connection => {
|
||||
removeConnection(userId, connection);
|
||||
});
|
||||
});
|
||||
}
|
||||
282
sveltekit-integration/src/lib/server/websocket-server.ts
Normal file
282
sveltekit-integration/src/lib/server/websocket-server.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { createServer } from 'http';
|
||||
import { parse } from 'url';
|
||||
import { prisma } from '$db';
|
||||
|
||||
export interface WebhookEvent {
|
||||
id: string;
|
||||
type: 'webhook' | 'system' | 'ping' | 'pong';
|
||||
data: any;
|
||||
}
|
||||
|
||||
export interface WSConnection {
|
||||
ws: any;
|
||||
userId: string;
|
||||
connected: boolean;
|
||||
lastPing: number;
|
||||
}
|
||||
|
||||
// Connection storage
|
||||
const connections = new Map<string, Set<WSConnection>>();
|
||||
let wss: WebSocketServer | null = null;
|
||||
let httpServer: any = null;
|
||||
|
||||
/**
|
||||
* Initialize WebSocket server on a separate port
|
||||
*/
|
||||
export function initWebSocketServer(port = 4001) {
|
||||
if (wss) return { wss, httpServer };
|
||||
|
||||
// Create HTTP server for WebSocket upgrade
|
||||
httpServer = createServer();
|
||||
|
||||
wss = new WebSocketServer({
|
||||
server: httpServer,
|
||||
verifyClient: async (info) => {
|
||||
try {
|
||||
// Extract token from query params or headers
|
||||
const url = parse(info.req.url!, true);
|
||||
const token = url.query.token as string;
|
||||
|
||||
if (!token) {
|
||||
console.log('WebSocket rejected: No token');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify session token
|
||||
const session = await prisma.session.findUnique({
|
||||
where: { sessionToken: token },
|
||||
include: { user: true }
|
||||
});
|
||||
|
||||
if (!session || session.expires < new Date()) {
|
||||
console.log('WebSocket rejected: Invalid token');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Store user info for this request
|
||||
(info.req as any).userId = session.userId;
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('WebSocket verification error:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
wss.on('connection', (ws, req) => {
|
||||
const userId = (req as any).userId;
|
||||
|
||||
if (!userId) {
|
||||
ws.close(1008, 'Authentication required');
|
||||
return;
|
||||
}
|
||||
|
||||
const connection = addConnection(userId, ws);
|
||||
|
||||
// Handle messages
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
handleMessage(connection, message);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse message:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle connection close
|
||||
ws.on('close', () => {
|
||||
removeConnection(userId, connection);
|
||||
});
|
||||
|
||||
// Handle errors
|
||||
ws.on('error', (error) => {
|
||||
console.error(`WebSocket error for user ${userId}:`, error);
|
||||
removeConnection(userId, connection);
|
||||
});
|
||||
});
|
||||
|
||||
// Start HTTP server
|
||||
httpServer.listen(port, () => {
|
||||
console.log(`WebSocket server listening on port ${port}`);
|
||||
});
|
||||
|
||||
// Cleanup stale connections every 5 minutes
|
||||
setInterval(cleanupStaleConnections, 5 * 60 * 1000);
|
||||
|
||||
return { wss, httpServer };
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a WebSocket connection
|
||||
*/
|
||||
function addConnection(userId: string, ws: any): WSConnection {
|
||||
const connection: WSConnection = {
|
||||
ws,
|
||||
userId,
|
||||
connected: true,
|
||||
lastPing: Date.now()
|
||||
};
|
||||
|
||||
if (!connections.has(userId)) {
|
||||
connections.set(userId, new Set());
|
||||
}
|
||||
|
||||
connections.get(userId)!.add(connection);
|
||||
|
||||
// Send welcome message
|
||||
sendMessage(connection, {
|
||||
id: crypto.randomUUID(),
|
||||
type: 'system',
|
||||
data: {
|
||||
message: 'Connected to webhook relay',
|
||||
timestamp: new Date().toISOString(),
|
||||
connectionCount: connections.get(userId)?.size || 1
|
||||
}
|
||||
});
|
||||
|
||||
return connection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a WebSocket connection
|
||||
*/
|
||||
function removeConnection(userId: string, connection: WSConnection) {
|
||||
const userConnections = connections.get(userId);
|
||||
if (userConnections) {
|
||||
userConnections.delete(connection);
|
||||
if (userConnections.size === 0) {
|
||||
connections.delete(userId);
|
||||
}
|
||||
}
|
||||
connection.connected = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming messages
|
||||
*/
|
||||
function handleMessage(connection: WSConnection, message: any) {
|
||||
connection.lastPing = Date.now();
|
||||
|
||||
switch (message.type) {
|
||||
case 'ping':
|
||||
sendMessage(connection, {
|
||||
id: crypto.randomUUID(),
|
||||
type: 'pong',
|
||||
data: { timestamp: new Date().toISOString() }
|
||||
});
|
||||
break;
|
||||
case 'subscribe':
|
||||
// Handle subscription to specific event types
|
||||
break;
|
||||
default:
|
||||
console.log(`Unknown message type: ${message.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to a specific connection
|
||||
*/
|
||||
function sendMessage(connection: WSConnection, event: WebhookEvent) {
|
||||
try {
|
||||
if (connection.connected && connection.ws.readyState === 1) { // OPEN
|
||||
connection.ws.send(JSON.stringify(event));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to send message:', error);
|
||||
connection.connected = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast to all user connections
|
||||
*/
|
||||
export async function broadcastToUser(userId: string, event: WebhookEvent): Promise<boolean> {
|
||||
const userConnections = connections.get(userId);
|
||||
|
||||
if (!userConnections || userConnections.size === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let successCount = 0;
|
||||
const failedConnections: WSConnection[] = [];
|
||||
|
||||
userConnections.forEach(connection => {
|
||||
try {
|
||||
if (connection.connected && connection.ws.readyState === 1) {
|
||||
sendMessage(connection, event);
|
||||
successCount++;
|
||||
} else {
|
||||
failedConnections.push(connection);
|
||||
}
|
||||
} catch (error) {
|
||||
failedConnections.push(connection);
|
||||
}
|
||||
});
|
||||
|
||||
// Remove failed connections
|
||||
failedConnections.forEach(connection => {
|
||||
removeConnection(userId, connection);
|
||||
});
|
||||
|
||||
return successCount > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection statistics
|
||||
*/
|
||||
export function getStats() {
|
||||
let totalConnections = 0;
|
||||
const userStats = new Map<string, number>();
|
||||
|
||||
connections.forEach((userConnections, userId) => {
|
||||
const activeCount = Array.from(userConnections).filter(c => c.connected).length;
|
||||
totalConnections += activeCount;
|
||||
userStats.set(userId, activeCount);
|
||||
});
|
||||
|
||||
return {
|
||||
totalUsers: connections.size,
|
||||
totalConnections,
|
||||
userStats
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup stale connections
|
||||
*/
|
||||
function cleanupStaleConnections() {
|
||||
const now = Date.now();
|
||||
const staleThreshold = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
connections.forEach((userConnections, userId) => {
|
||||
const staleConnections: WSConnection[] = [];
|
||||
|
||||
userConnections.forEach(connection => {
|
||||
if (!connection.connected ||
|
||||
(now - connection.lastPing > staleThreshold)) {
|
||||
staleConnections.push(connection);
|
||||
}
|
||||
});
|
||||
|
||||
staleConnections.forEach(connection => {
|
||||
removeConnection(userId, connection);
|
||||
});
|
||||
});
|
||||
|
||||
console.log('Cleaned up stale WebSocket connections');
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown WebSocket server
|
||||
*/
|
||||
export function shutdown() {
|
||||
if (wss) {
|
||||
wss.close();
|
||||
wss = null;
|
||||
}
|
||||
if (httpServer) {
|
||||
httpServer.close();
|
||||
httpServer = null;
|
||||
}
|
||||
}
|
||||
209
sveltekit-integration/src/lib/server/websocket.ts
Normal file
209
sveltekit-integration/src/lib/server/websocket.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { parse } from 'url';
|
||||
import { verify } from 'jsonwebtoken';
|
||||
import { prisma } from '$db';
|
||||
|
||||
// Store for WebSocket connections
|
||||
const wsConnections = new Map<string, Set<any>>();
|
||||
|
||||
export interface WebhookEvent {
|
||||
id: string;
|
||||
type: 'webhook' | 'system';
|
||||
data: any;
|
||||
}
|
||||
|
||||
let wss: WebSocketServer | null = null;
|
||||
|
||||
/**
|
||||
* Initialize WebSocket server
|
||||
*/
|
||||
export function initWebSocketServer(server: any) {
|
||||
if (wss) return wss;
|
||||
|
||||
wss = new WebSocketServer({
|
||||
server,
|
||||
path: '/api/relay/ws',
|
||||
verifyClient: async (info) => {
|
||||
try {
|
||||
// Extract session token from URL or headers
|
||||
const url = parse(info.req.url!, true);
|
||||
const token = url.query.token as string ||
|
||||
info.req.headers.authorization?.replace('Bearer ', '');
|
||||
|
||||
if (!token) {
|
||||
console.log('WebSocket connection rejected: No token provided');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify session token (simplified - in production use proper JWT verification)
|
||||
const session = await prisma.session.findUnique({
|
||||
where: { sessionToken: token },
|
||||
include: { user: true }
|
||||
});
|
||||
|
||||
if (!session || session.expires < new Date()) {
|
||||
console.log('WebSocket connection rejected: Invalid or expired token');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Store user info for connection
|
||||
(info.req as any).userId = session.userId;
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('WebSocket verification error:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
wss.on('connection', (ws, req) => {
|
||||
const userId = (req as any).userId;
|
||||
|
||||
if (!userId) {
|
||||
ws.close(1008, 'Invalid authentication');
|
||||
return;
|
||||
}
|
||||
|
||||
// Add connection
|
||||
addWSConnection(userId, ws);
|
||||
|
||||
// Handle messages
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
console.log(`WebSocket message from user ${userId}:`, message);
|
||||
|
||||
// Handle ping/pong for connection health
|
||||
if (message.type === 'ping') {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'pong',
|
||||
timestamp: new Date().toISOString()
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse WebSocket message:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle connection close
|
||||
ws.on('close', () => {
|
||||
removeWSConnection(userId, ws);
|
||||
});
|
||||
|
||||
// Handle errors
|
||||
ws.on('error', (error) => {
|
||||
console.error(`WebSocket error for user ${userId}:`, error);
|
||||
removeWSConnection(userId, ws);
|
||||
});
|
||||
});
|
||||
|
||||
console.log('WebSocket server initialized on path /api/relay/ws');
|
||||
return wss;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a WebSocket connection for a user
|
||||
*/
|
||||
export function addWSConnection(userId: string, ws: any) {
|
||||
if (!wsConnections.has(userId)) {
|
||||
wsConnections.set(userId, new Set());
|
||||
}
|
||||
wsConnections.get(userId)!.add(ws);
|
||||
|
||||
// Send initial connection message
|
||||
sendWSMessage(ws, {
|
||||
id: crypto.randomUUID(),
|
||||
type: 'system',
|
||||
data: {
|
||||
message: 'Connected to webhook relay',
|
||||
timestamp: new Date().toISOString(),
|
||||
userId
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`WebSocket connected for user ${userId}. Total connections: ${wsConnections.get(userId)?.size}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a WebSocket connection for a user
|
||||
*/
|
||||
export function removeWSConnection(userId: string, ws: any) {
|
||||
const userConnections = wsConnections.get(userId);
|
||||
if (userConnections) {
|
||||
userConnections.delete(ws);
|
||||
if (userConnections.size === 0) {
|
||||
wsConnections.delete(userId);
|
||||
}
|
||||
console.log(`WebSocket disconnected for user ${userId}. Remaining connections: ${userConnections.size}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to a specific WebSocket connection
|
||||
*/
|
||||
function sendWSMessage(ws: any, event: WebhookEvent) {
|
||||
try {
|
||||
if (ws.readyState === 1) { // WebSocket.OPEN
|
||||
ws.send(JSON.stringify(event));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to send WebSocket message:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast event to all connections for a specific user
|
||||
*/
|
||||
export async function broadcastToUser(userId: string, event: WebhookEvent): Promise<boolean> {
|
||||
const userConnections = wsConnections.get(userId);
|
||||
|
||||
if (!userConnections || userConnections.size === 0) {
|
||||
console.log(`No WebSocket connections found for user ${userId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
let successCount = 0;
|
||||
const totalConnections = userConnections.size;
|
||||
const failedConnections: any[] = [];
|
||||
|
||||
userConnections.forEach(ws => {
|
||||
try {
|
||||
if (ws.readyState === 1) { // WebSocket.OPEN
|
||||
sendWSMessage(ws, event);
|
||||
successCount++;
|
||||
} else {
|
||||
// Mark for removal if connection is closed
|
||||
failedConnections.push(ws);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to broadcast to WebSocket connection:', error);
|
||||
failedConnections.push(ws);
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up failed connections
|
||||
failedConnections.forEach(ws => {
|
||||
userConnections.delete(ws);
|
||||
});
|
||||
|
||||
console.log(`Broadcast to ${successCount}/${totalConnections} connections for user ${userId}`);
|
||||
return successCount > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection count for a user
|
||||
*/
|
||||
export function getConnectionCount(userId: string): number {
|
||||
return wsConnections.get(userId)?.size || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total connection count
|
||||
*/
|
||||
export function getTotalConnections(): number {
|
||||
let total = 0;
|
||||
wsConnections.forEach(connections => {
|
||||
total += connections.size;
|
||||
});
|
||||
return total;
|
||||
}
|
||||
@@ -35,54 +35,107 @@ export const recentEvents = derived(webhookEvents, $events =>
|
||||
$events.slice(0, 10)
|
||||
);
|
||||
|
||||
// SSE Connection management
|
||||
let eventSource: EventSource | null = null;
|
||||
// WebSocket Connection management
|
||||
let websocket: WebSocket | null = null;
|
||||
let reconnectTimeout: number | null = null;
|
||||
let pingInterval: number | null = null;
|
||||
|
||||
export const webhookStore = {
|
||||
// Initialize SSE connection
|
||||
connect: () => {
|
||||
// Initialize WebSocket connection
|
||||
connect: async () => {
|
||||
if (!browser) return;
|
||||
|
||||
connectionStatus.set('connecting');
|
||||
|
||||
eventSource = new EventSource('/api/relay/events');
|
||||
// Create WebSocket connection to separate WebSocket server
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsPort = 4001; // WebSocket server port
|
||||
|
||||
eventSource.onopen = () => {
|
||||
connectionStatus.set('connected');
|
||||
};
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'webhook') {
|
||||
webhookEvents.update(events => [data.data, ...events].slice(0, 100));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse SSE message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
eventSource.onerror = () => {
|
||||
// Get session token from cookie for authentication
|
||||
const sessionToken = document.cookie
|
||||
.split('; ')
|
||||
.find(row => row.startsWith('authjs.session-token='))
|
||||
?.split('=')[1];
|
||||
|
||||
if (!sessionToken) {
|
||||
console.error('No session token found');
|
||||
connectionStatus.set('disconnected');
|
||||
// Attempt to reconnect after 3 seconds
|
||||
setTimeout(() => {
|
||||
if (eventSource?.readyState === EventSource.CLOSED) {
|
||||
webhookStore.connect();
|
||||
return;
|
||||
}
|
||||
|
||||
const wsUrl = `${protocol}//${window.location.hostname}:${wsPort}?token=${sessionToken}`;
|
||||
|
||||
try {
|
||||
websocket = new WebSocket(wsUrl);
|
||||
|
||||
websocket.onopen = () => {
|
||||
connectionStatus.set('connected');
|
||||
console.log('WebSocket connected');
|
||||
startPingInterval();
|
||||
};
|
||||
|
||||
websocket.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
handleWebSocketMessage(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse WebSocket message:', error);
|
||||
}
|
||||
}, 3000);
|
||||
};
|
||||
};
|
||||
|
||||
websocket.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
connectionStatus.set('disconnected');
|
||||
};
|
||||
|
||||
websocket.onclose = (event) => {
|
||||
console.log('WebSocket closed:', event.code, event.reason);
|
||||
connectionStatus.set('disconnected');
|
||||
websocket = null;
|
||||
|
||||
// Clear ping interval
|
||||
if (pingInterval) {
|
||||
clearInterval(pingInterval);
|
||||
pingInterval = null;
|
||||
}
|
||||
|
||||
// Attempt to reconnect if not a normal closure
|
||||
if (event.code !== 1000) {
|
||||
scheduleReconnect();
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to create WebSocket connection:', error);
|
||||
connectionStatus.set('disconnected');
|
||||
}
|
||||
},
|
||||
|
||||
// Disconnect SSE
|
||||
// Disconnect WebSocket
|
||||
disconnect: () => {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
if (reconnectTimeout) {
|
||||
clearTimeout(reconnectTimeout);
|
||||
reconnectTimeout = null;
|
||||
}
|
||||
|
||||
if (pingInterval) {
|
||||
clearInterval(pingInterval);
|
||||
pingInterval = null;
|
||||
}
|
||||
|
||||
if (websocket) {
|
||||
websocket.close(1000, 'User disconnect');
|
||||
websocket = null;
|
||||
}
|
||||
connectionStatus.set('disconnected');
|
||||
},
|
||||
|
||||
// Send message through WebSocket
|
||||
send: (message: any) => {
|
||||
if (websocket && websocket.readyState === WebSocket.OPEN) {
|
||||
websocket.send(JSON.stringify(message));
|
||||
}
|
||||
},
|
||||
|
||||
// Load initial webhook history
|
||||
loadHistory: async () => {
|
||||
if (!browser) return;
|
||||
@@ -162,4 +215,52 @@ export const webhookStore = {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle incoming WebSocket messages
|
||||
*/
|
||||
function handleWebSocketMessage(data: any) {
|
||||
switch (data.type) {
|
||||
case 'webhook':
|
||||
webhookEvents.update(events => [data.data, ...events].slice(0, 100));
|
||||
break;
|
||||
case 'system':
|
||||
console.log('System message:', data.data.message);
|
||||
break;
|
||||
case 'pong':
|
||||
// Connection is alive
|
||||
break;
|
||||
default:
|
||||
console.log('Unknown WebSocket message type:', data.type);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start ping interval to keep connection alive
|
||||
*/
|
||||
function startPingInterval() {
|
||||
if (pingInterval) clearInterval(pingInterval);
|
||||
|
||||
pingInterval = setInterval(() => {
|
||||
if (websocket && websocket.readyState === WebSocket.OPEN) {
|
||||
webhookStore.send({ type: 'ping', timestamp: Date.now() });
|
||||
} else if (pingInterval) {
|
||||
clearInterval(pingInterval);
|
||||
pingInterval = null;
|
||||
}
|
||||
}, 30000) as any; // Ping every 30 seconds
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule reconnection attempt
|
||||
*/
|
||||
function scheduleReconnect() {
|
||||
if (reconnectTimeout) return;
|
||||
|
||||
reconnectTimeout = setTimeout(() => {
|
||||
reconnectTimeout = null;
|
||||
console.log('Attempting to reconnect WebSocket...');
|
||||
webhookStore.connect();
|
||||
}, 3000) as any;
|
||||
}
|
||||
20
sveltekit-integration/src/routes/api/auth/session/+server.ts
Normal file
20
sveltekit-integration/src/routes/api/auth/session/+server.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async ({ locals, cookies }) => {
|
||||
const session = await locals.auth();
|
||||
|
||||
if (!session?.user) {
|
||||
return json({ session: null });
|
||||
}
|
||||
|
||||
// Get session token from cookies for WebSocket authentication
|
||||
const sessionToken = cookies.get('authjs.session-token');
|
||||
|
||||
return json({
|
||||
session: {
|
||||
user: session.user,
|
||||
sessionToken // Include for WebSocket auth
|
||||
}
|
||||
});
|
||||
};
|
||||
134
sveltekit-integration/src/routes/api/relay/ws/+server.ts
Normal file
134
sveltekit-integration/src/routes/api/relay/ws/+server.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import type { RequestHandler } from './$types';
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
// Simple in-memory WebSocket connection store
|
||||
const connections = new Map<string, Set<WebSocket>>();
|
||||
|
||||
export interface WebhookEvent {
|
||||
id: string;
|
||||
type: 'webhook' | 'system' | 'ping' | 'pong';
|
||||
data: any;
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async ({ request, locals, url }) => {
|
||||
const session = await locals.auth();
|
||||
|
||||
if (!session?.user?.id) {
|
||||
throw error(401, 'Unauthorized');
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
|
||||
// Check for WebSocket upgrade
|
||||
const upgrade = request.headers.get('upgrade');
|
||||
const connection = request.headers.get('connection');
|
||||
|
||||
if (upgrade?.toLowerCase() !== 'websocket' || !connection?.toLowerCase().includes('upgrade')) {
|
||||
return new Response('WebSocket upgrade required', {
|
||||
status: 426,
|
||||
headers: {
|
||||
'Upgrade': 'websocket',
|
||||
'Connection': 'Upgrade'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// For compatibility, we'll use a simple WebSocket response
|
||||
// This works with most WebSocket implementations
|
||||
const webSocketKey = request.headers.get('sec-websocket-key');
|
||||
if (!webSocketKey) {
|
||||
throw error(400, 'Missing WebSocket key');
|
||||
}
|
||||
|
||||
// Create WebSocket response headers
|
||||
const acceptKey = await generateWebSocketAccept(webSocketKey);
|
||||
|
||||
return new Response(null, {
|
||||
status: 101,
|
||||
headers: {
|
||||
'Upgrade': 'websocket',
|
||||
'Connection': 'Upgrade',
|
||||
'Sec-WebSocket-Accept': acceptKey,
|
||||
}
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error('WebSocket upgrade error:', err);
|
||||
throw error(500, 'WebSocket upgrade failed');
|
||||
}
|
||||
};
|
||||
|
||||
// Generate WebSocket accept key
|
||||
async function generateWebSocketAccept(key: string): Promise<string> {
|
||||
const concatenated = key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
||||
const hash = await crypto.subtle.digest('SHA-1', new TextEncoder().encode(concatenated));
|
||||
return btoa(String.fromCharCode(...new Uint8Array(hash)));
|
||||
}
|
||||
|
||||
// Export connection management functions
|
||||
export function addConnection(userId: string, ws: WebSocket) {
|
||||
if (!connections.has(userId)) {
|
||||
connections.set(userId, new Set());
|
||||
}
|
||||
connections.get(userId)!.add(ws);
|
||||
|
||||
// Send welcome message
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
id: crypto.randomUUID(),
|
||||
type: 'system',
|
||||
data: {
|
||||
message: 'Connected to webhook relay',
|
||||
timestamp: new Date().toISOString(),
|
||||
userId
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
console.log(`WebSocket connected for user ${userId}`);
|
||||
}
|
||||
|
||||
export function removeConnection(userId: string, ws: WebSocket) {
|
||||
const userConnections = connections.get(userId);
|
||||
if (userConnections) {
|
||||
userConnections.delete(ws);
|
||||
if (userConnections.size === 0) {
|
||||
connections.delete(userId);
|
||||
}
|
||||
}
|
||||
console.log(`WebSocket disconnected for user ${userId}`);
|
||||
}
|
||||
|
||||
export async function broadcastToUser(userId: string, event: WebhookEvent): Promise<boolean> {
|
||||
const userConnections = connections.get(userId);
|
||||
|
||||
if (!userConnections || userConnections.size === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let successCount = 0;
|
||||
const message = JSON.stringify(event);
|
||||
const staleConnections: WebSocket[] = [];
|
||||
|
||||
userConnections.forEach(ws => {
|
||||
try {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(message);
|
||||
successCount++;
|
||||
} else {
|
||||
staleConnections.push(ws);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to send message:', error);
|
||||
staleConnections.push(ws);
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up stale connections
|
||||
staleConnections.forEach(ws => {
|
||||
userConnections.delete(ws);
|
||||
});
|
||||
|
||||
return successCount > 0;
|
||||
}
|
||||
85
sveltekit-integration/src/routes/api/test-webhook/+server.ts
Normal file
85
sveltekit-integration/src/routes/api/test-webhook/+server.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
const session = await locals.auth();
|
||||
|
||||
if (!session?.user?.subdomain) {
|
||||
return json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
|
||||
const subdomain = session.user.subdomain;
|
||||
|
||||
try {
|
||||
// Test different webhook payload types
|
||||
const testPayloads = [
|
||||
{
|
||||
type: 'json',
|
||||
contentType: 'application/json',
|
||||
payload: {
|
||||
test: true,
|
||||
message: 'Test JSON webhook',
|
||||
timestamp: new Date().toISOString(),
|
||||
data: {
|
||||
nested: {
|
||||
value: 'test'
|
||||
},
|
||||
array: [1, 2, 3]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'form',
|
||||
contentType: 'application/x-www-form-urlencoded',
|
||||
payload: 'test=true&message=Test+form+webhook×tamp=' + encodeURIComponent(new Date().toISOString())
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
contentType: 'text/plain',
|
||||
payload: 'Test plain text webhook payload'
|
||||
}
|
||||
];
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const test of testPayloads) {
|
||||
try {
|
||||
const response = await fetch(`${request.url.origin}/api/webhook/${subdomain}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': test.contentType,
|
||||
'User-Agent': 'WebhookRelay-Test/1.0',
|
||||
'X-Test-Type': test.type
|
||||
},
|
||||
body: typeof test.payload === 'string' ? test.payload : JSON.stringify(test.payload)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
results.push({
|
||||
type: test.type,
|
||||
success: response.ok,
|
||||
result
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
type: test.type,
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return json({
|
||||
message: 'Webhook ingestion tests completed',
|
||||
subdomain,
|
||||
results,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
return json({
|
||||
error: 'Test failed',
|
||||
details: error instanceof Error ? error.message : 'Unknown error'
|
||||
}, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { prisma } from '$db';
|
||||
import { broadcastToUser } from '$lib/server/relay';
|
||||
import { broadcastToUser, forwardToRelayTargets } from '$lib/server/relay';
|
||||
|
||||
export const POST: RequestHandler = async ({ request, params, url }) => {
|
||||
const { subdomain } = params;
|
||||
@@ -20,65 +20,120 @@ export const POST: RequestHandler = async ({ request, params, url }) => {
|
||||
throw error(404, 'Invalid subdomain');
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
// Enhanced body parsing to handle all webhook types
|
||||
let body: any = null;
|
||||
const contentType = request.headers.get('content-type');
|
||||
let rawBody = '';
|
||||
const contentType = request.headers.get('content-type') || '';
|
||||
|
||||
if (contentType?.includes('application/json')) {
|
||||
body = await request.json();
|
||||
} else if (contentType?.includes('application/x-www-form-urlencoded')) {
|
||||
const formData = await request.formData();
|
||||
body = Object.fromEntries(formData);
|
||||
} else {
|
||||
body = await request.text();
|
||||
try {
|
||||
// Always get raw body first for signature verification
|
||||
rawBody = await request.text();
|
||||
|
||||
if (contentType.includes('application/json')) {
|
||||
body = JSON.parse(rawBody);
|
||||
} else if (contentType.includes('application/x-www-form-urlencoded')) {
|
||||
const formData = new URLSearchParams(rawBody);
|
||||
body = Object.fromEntries(formData);
|
||||
} else if (contentType.includes('multipart/form-data')) {
|
||||
// For multipart, we need to re-read as FormData
|
||||
const clonedRequest = request.clone();
|
||||
const formData = await clonedRequest.formData();
|
||||
body = Object.fromEntries(formData);
|
||||
} else if (contentType.includes('application/xml') || contentType.includes('text/xml')) {
|
||||
body = rawBody; // Keep XML as string
|
||||
} else {
|
||||
body = rawBody; // Keep as raw text for other types
|
||||
}
|
||||
} catch (parseError) {
|
||||
// If parsing fails, keep raw body
|
||||
body = rawBody;
|
||||
}
|
||||
|
||||
// Collect headers (excluding sensitive ones)
|
||||
// Collect all headers (excluding sensitive ones)
|
||||
const headers: Record<string, string> = {};
|
||||
request.headers.forEach((value, key) => {
|
||||
if (!key.toLowerCase().includes('authorization') &&
|
||||
!key.toLowerCase().includes('cookie')) {
|
||||
const lowerKey = key.toLowerCase();
|
||||
if (!lowerKey.includes('authorization') &&
|
||||
!lowerKey.includes('cookie') &&
|
||||
!lowerKey.includes('session')) {
|
||||
headers[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
// Create webhook event record
|
||||
// Create comprehensive webhook event record
|
||||
const webhookEvent = {
|
||||
userId: user.id,
|
||||
method: request.method,
|
||||
path: url.pathname,
|
||||
query: url.search,
|
||||
body: JSON.stringify(body),
|
||||
query: url.search || '',
|
||||
body: typeof body === 'string' ? body : JSON.stringify(body),
|
||||
headers: JSON.stringify(headers),
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
// Store in database
|
||||
const savedEvent = await prisma.webhookEvent.create({
|
||||
data: webhookEvent
|
||||
});
|
||||
// Store in database with error handling
|
||||
let savedEvent;
|
||||
try {
|
||||
savedEvent = await prisma.webhookEvent.create({
|
||||
data: webhookEvent
|
||||
});
|
||||
} catch (dbError) {
|
||||
console.error('Database storage error:', dbError);
|
||||
// Continue processing even if DB fails
|
||||
savedEvent = { id: 'temp-' + Date.now(), ...webhookEvent };
|
||||
}
|
||||
|
||||
// Broadcast to connected clients via SSE
|
||||
const broadcastSuccess = await broadcastToUser(user.id, {
|
||||
// Prepare broadcast data
|
||||
const broadcastData = {
|
||||
id: savedEvent.id,
|
||||
type: 'webhook',
|
||||
type: 'webhook' as const,
|
||||
data: {
|
||||
...webhookEvent,
|
||||
timestamp: savedEvent.createdAt.toISOString()
|
||||
body: body, // Send parsed body for frontend
|
||||
headers: headers, // Send parsed headers
|
||||
timestamp: savedEvent.createdAt.toISOString(),
|
||||
contentType
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Broadcast to connected WebSocket clients
|
||||
const broadcastSuccess = await broadcastToUser(user.id, broadcastData);
|
||||
|
||||
// Forward to relay targets if configured
|
||||
let forwardResults: any[] = [];
|
||||
try {
|
||||
forwardResults = await forwardToRelayTargets(user.id, {
|
||||
...broadcastData.data,
|
||||
originalUrl: url.href,
|
||||
userAgent: headers['user-agent'] || 'Unknown'
|
||||
});
|
||||
} catch (forwardError) {
|
||||
console.error('Relay forwarding error:', forwardError);
|
||||
}
|
||||
|
||||
// Return comprehensive response
|
||||
return json({
|
||||
success: true,
|
||||
logged: true,
|
||||
logged: !!savedEvent.id && !savedEvent.id.startsWith('temp-'),
|
||||
forwarded: broadcastSuccess,
|
||||
relayResults: forwardResults,
|
||||
subdomain,
|
||||
eventId: savedEvent.id
|
||||
eventId: savedEvent.id,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error('Webhook processing error:', err);
|
||||
throw error(500, 'Failed to process webhook');
|
||||
|
||||
// Still return success for webhook senders, but log the error
|
||||
return json({
|
||||
success: true,
|
||||
logged: false,
|
||||
forwarded: false,
|
||||
error: 'Internal processing error',
|
||||
subdomain,
|
||||
timestamp: new Date().toISOString()
|
||||
}, { status: 200 }); // Return 200 to prevent webhook retries
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { webhookEvents, connectionStatus, recentEvents } from '$lib/stores/webhooks';
|
||||
import ConnectionStatus from '$lib/components/ConnectionStatus.svelte';
|
||||
import WebSocketStatus from '$lib/components/WebSocketStatus.svelte';
|
||||
import WebhookEventCard from '$lib/components/WebhookEventCard.svelte';
|
||||
|
||||
export let data;
|
||||
@@ -44,13 +44,7 @@
|
||||
<div class="p-5">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<ConnectionStatus />
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Connection</dt>
|
||||
<dd class="text-lg font-medium text-gray-900 capitalize">{$connectionStatus}</dd>
|
||||
</dl>
|
||||
<WebSocketStatus />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -89,6 +83,47 @@
|
||||
<p class="mt-2 text-sm text-gray-500">
|
||||
Send webhooks to this endpoint. All events will be logged and forwarded to your connected relay targets.
|
||||
</p>
|
||||
<div class="mt-4 flex space-x-3">
|
||||
<button
|
||||
on:click={async () => {
|
||||
try {
|
||||
const response = await fetch('/api/test-webhook', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
const result = await response.json();
|
||||
console.log('Test webhook results:', result);
|
||||
alert(`Test completed! ${result.results?.length || 0} webhook tests run successfully.`);
|
||||
} catch (error) {
|
||||
console.error('Failed to run webhook tests:', error);
|
||||
alert('Test failed. Check console for details.');
|
||||
}
|
||||
}}
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-3 py-2 rounded-md text-sm font-medium"
|
||||
>
|
||||
Run Full Test Suite
|
||||
</button>
|
||||
<button
|
||||
on:click={async () => {
|
||||
try {
|
||||
await fetch(`/api/webhook/${user?.subdomain}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
test: true,
|
||||
message: 'Simple test from dashboard',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to send test webhook:', error);
|
||||
}
|
||||
}}
|
||||
class="bg-gray-600 hover:bg-gray-700 text-white px-3 py-2 rounded-md text-sm font-medium"
|
||||
>
|
||||
Send Simple Test
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { webhookEvents, isLoading } from '$lib/stores/webhooks';
|
||||
import WebhookEventCard from '$lib/components/WebhookEventCard.svelte';
|
||||
import ConnectionStatus from '$lib/components/ConnectionStatus.svelte';
|
||||
import WebSocketStatus from '$lib/components/WebSocketStatus.svelte';
|
||||
|
||||
export let data;
|
||||
|
||||
@@ -23,8 +23,7 @@
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<ConnectionStatus />
|
||||
<span class="text-sm text-gray-500">Live updates</span>
|
||||
<WebSocketStatus />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
193
sveltekit-integration/test-client.js
Normal file
193
sveltekit-integration/test-client.js
Normal file
@@ -0,0 +1,193 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Comprehensive webhook testing client for the SvelteKit implementation
|
||||
|
||||
const testSubdomain = process.argv[2] || 'test-user';
|
||||
const baseUrl = process.argv[3] || 'http://localhost:5173';
|
||||
|
||||
const webhookUrl = `${baseUrl}/api/webhook/${testSubdomain}`;
|
||||
|
||||
console.log(`Testing webhook ingestion at: ${webhookUrl}`);
|
||||
console.log('='.repeat(60));
|
||||
|
||||
// Test cases for different webhook types
|
||||
const testCases = [
|
||||
{
|
||||
name: 'JSON Webhook (GitHub-style)',
|
||||
contentType: 'application/json',
|
||||
headers: {
|
||||
'X-GitHub-Event': 'push',
|
||||
'X-GitHub-Delivery': '12345-67890',
|
||||
'User-Agent': 'GitHub-Hookshot/abc123'
|
||||
},
|
||||
body: {
|
||||
ref: 'refs/heads/main',
|
||||
before: 'abc123',
|
||||
after: 'def456',
|
||||
repository: {
|
||||
name: 'test-repo',
|
||||
full_name: 'user/test-repo',
|
||||
private: false
|
||||
},
|
||||
pusher: {
|
||||
name: 'testuser',
|
||||
email: 'test@example.com'
|
||||
},
|
||||
commits: [
|
||||
{
|
||||
id: 'def456',
|
||||
message: 'Test commit',
|
||||
author: {
|
||||
name: 'Test User',
|
||||
email: 'test@example.com'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Form Data Webhook (Stripe-style)',
|
||||
contentType: 'application/x-www-form-urlencoded',
|
||||
headers: {
|
||||
'Stripe-Signature': 't=1234567890,v1=test_signature',
|
||||
'User-Agent': 'Stripe/1.0'
|
||||
},
|
||||
body: 'id=evt_test&object=event&type=payment_intent.succeeded&data[object][id]=pi_test&data[object][amount]=2000&data[object][currency]=usd'
|
||||
},
|
||||
{
|
||||
name: 'XML Webhook (PayPal-style)',
|
||||
contentType: 'application/xml',
|
||||
headers: {
|
||||
'PayPal-Auth-Algo': 'SHA256withRSA',
|
||||
'User-Agent': 'PayPal/AUHD-214.0-52392296'
|
||||
},
|
||||
body: '<?xml version="1.0" encoding="UTF-8"?><notification><timestamp>2024-01-01T12:00:00Z</timestamp><event_type>PAYMENT.CAPTURE.COMPLETED</event_type><resource><id>PAYMENT123</id><amount><currency_code>USD</currency_code><value>25.00</value></amount></resource></notification>'
|
||||
},
|
||||
{
|
||||
name: 'Plain Text Webhook',
|
||||
contentType: 'text/plain',
|
||||
headers: {
|
||||
'X-Custom-Header': 'test-value',
|
||||
'User-Agent': 'CustomWebhookSender/1.0'
|
||||
},
|
||||
body: 'Simple plain text webhook payload with some test data'
|
||||
},
|
||||
{
|
||||
name: 'Large JSON Payload',
|
||||
contentType: 'application/json',
|
||||
headers: {
|
||||
'X-Event-Type': 'bulk-update',
|
||||
'User-Agent': 'BulkProcessor/2.0'
|
||||
},
|
||||
body: {
|
||||
event: 'bulk_update',
|
||||
timestamp: new Date().toISOString(),
|
||||
data: Array.from({ length: 100 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
name: `Item ${i + 1}`,
|
||||
value: Math.random() * 1000,
|
||||
tags: [`tag${i % 5}`, `category${i % 3}`],
|
||||
metadata: {
|
||||
created: new Date(Date.now() - Math.random() * 86400000).toISOString(),
|
||||
updated: new Date().toISOString()
|
||||
}
|
||||
}))
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Webhook with Special Characters',
|
||||
contentType: 'application/json',
|
||||
headers: {
|
||||
'X-Special-Header': 'test with spaces & symbols!',
|
||||
'User-Agent': 'SpecialChar-Tester/1.0'
|
||||
},
|
||||
body: {
|
||||
message: 'Test with special characters: éñüñ 中文 🚀 💻',
|
||||
symbols: '!@#$%^&*()_+-=[]{}|;:,.<>?',
|
||||
unicode: '𝓤𝓷𝓲𝓬𝓸𝓭𝓮 𝓣𝓮𝔁𝓽',
|
||||
emoji: '🎉🔥💯✨🚀'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
async function runTest(testCase) {
|
||||
console.log(`\n🧪 Testing: ${testCase.name}`);
|
||||
console.log(` Content-Type: ${testCase.contentType}`);
|
||||
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
|
||||
const response = await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': testCase.contentType,
|
||||
...testCase.headers
|
||||
},
|
||||
body: typeof testCase.body === 'string' ? testCase.body : JSON.stringify(testCase.body)
|
||||
});
|
||||
|
||||
const endTime = Date.now();
|
||||
const responseTime = endTime - startTime;
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
console.log(` ✅ Status: ${response.status} (${responseTime}ms)`);
|
||||
console.log(` 📝 Logged: ${result.logged}`);
|
||||
console.log(` 📡 Forwarded: ${result.forwarded}`);
|
||||
console.log(` 🆔 Event ID: ${result.eventId}`);
|
||||
|
||||
if (result.relayResults && result.relayResults.length > 0) {
|
||||
console.log(` 🔄 Relay Results: ${result.relayResults.length} targets`);
|
||||
}
|
||||
|
||||
return { success: true, responseTime, result };
|
||||
} catch (error) {
|
||||
console.log(` ❌ Error: ${error.message}`);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function runAllTests() {
|
||||
console.log(`🚀 Starting webhook ingestion tests...`);
|
||||
console.log(`📍 Target URL: ${webhookUrl}`);
|
||||
console.log(`📅 Started at: ${new Date().toISOString()}`);
|
||||
|
||||
const results = [];
|
||||
let successCount = 0;
|
||||
let totalResponseTime = 0;
|
||||
|
||||
for (const testCase of testCases) {
|
||||
const result = await runTest(testCase);
|
||||
results.push({ testCase: testCase.name, ...result });
|
||||
|
||||
if (result.success) {
|
||||
successCount++;
|
||||
totalResponseTime += result.responseTime || 0;
|
||||
}
|
||||
|
||||
// Small delay between tests
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
console.log('\n' + '='.repeat(60));
|
||||
console.log('📊 TEST SUMMARY');
|
||||
console.log('='.repeat(60));
|
||||
console.log(`✅ Successful: ${successCount}/${testCases.length}`);
|
||||
console.log(`⏱️ Average Response Time: ${Math.round(totalResponseTime / successCount)}ms`);
|
||||
console.log(`📅 Completed at: ${new Date().toISOString()}`);
|
||||
|
||||
if (successCount === testCases.length) {
|
||||
console.log('\n🎉 All tests passed! Webhook ingestion is working correctly.');
|
||||
} else {
|
||||
console.log('\n⚠️ Some tests failed. Check the logs above for details.');
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// Run tests if called directly
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
runAllTests().catch(console.error);
|
||||
}
|
||||
|
||||
export { runAllTests, runTest, testCases };
|
||||
Reference in New Issue
Block a user