Packages
phoenix_echo
0.1.0
Forward browser console logs and errors to Phoenix server logs via WebSocket
Current section
Files
Jump to
Current section
Files
priv/static/phoenix_echo.js
(function() {
'use strict';
// Configuration
const SOCKET_PATH = '/__phoenix_echo/websocket';
const CHANNEL_TOPIC = 'browser:logs';
const RECONNECT_INTERVAL = 5000;
// Default options
const defaultOptions = {
levels: ['log', 'warn', 'error', 'info', 'debug'],
captureErrors: true,
captureRejections: true,
maxQueueSize: 100,
maxDepth: 10,
debug: false
};
// State
let socket = null;
let connected = false;
let messageQueue = [];
let isForwarding = false;
let errorHandler = null;
let rejectionHandler = null;
let config = { ...defaultOptions };
const originalConsole = {};
// Debug logging helper
function debugLog(...args) {
if (config.debug && originalConsole.log) {
originalConsole.log('[PhoenixEcho]', ...args);
}
}
// Serialize arguments for transmission
function serialize(args) {
return args.map(arg => serializeValue(arg, new WeakSet(), 0));
}
function serializeValue(value, seen, depth) {
if (depth > config.maxDepth) return '[MaxDepth]';
if (value === null) return null;
if (value === undefined) return 'undefined';
const type = typeof value;
if (type === 'string' || type === 'number' || type === 'boolean') {
return value;
}
if (type === 'function') {
return `[Function: ${value.name || 'anonymous'}]`;
}
if (type === 'symbol') {
return value.toString();
}
if (value instanceof Error) {
return {
__type: 'Error',
name: value.name,
message: value.message,
stack: value.stack
};
}
if (value instanceof Date) {
return value.toISOString();
}
if (value instanceof RegExp) {
return value.toString();
}
if (Array.isArray(value)) {
if (seen.has(value)) return '[Circular]';
seen.add(value);
return value.map(item => serializeValue(item, seen, depth + 1));
}
if (type === 'object') {
if (seen.has(value)) return '[Circular]';
seen.add(value);
// Handle DOM elements
if (value instanceof Element) {
return `[${value.tagName}${value.id ? '#' + value.id : ''}${value.className ? '.' + value.className.split(' ').join('.') : ''}]`;
}
// Handle Window
if (value === window) {
return '[Window]';
}
// Handle Document
if (value === document) {
return '[Document]';
}
const result = {};
try {
for (const key of Object.keys(value)) {
result[key] = serializeValue(value[key], seen, depth + 1);
}
} catch (e) {
return '[Object]';
}
return result;
}
return String(value);
}
// Simple Phoenix Socket implementation
function createSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}${SOCKET_PATH}?vsn=2.0.0`;
socket = new WebSocket(wsUrl);
socket.onopen = function() {
debugLog('WebSocket connected, joining channel:', CHANNEL_TOPIC);
// Join channel
const joinMsg = JSON.stringify([null, null, CHANNEL_TOPIC, 'phx_join', {}]);
socket.send(joinMsg);
};
socket.onmessage = function(event) {
try {
const [, , topic, event_name, payload] = JSON.parse(event.data);
debugLog('Received message:', event_name, payload);
if (topic === CHANNEL_TOPIC && event_name === 'phx_reply' && payload.status === 'ok') {
connected = true;
debugLog('Channel joined successfully, ready to send logs');
flushQueue();
} else if (payload.status === 'error') {
if (originalConsole.error) {
originalConsole.error('[PhoenixEcho] Channel join failed:', payload.response);
}
}
} catch (e) {
if (originalConsole.error) {
originalConsole.error('[PhoenixEcho] Parse error:', e);
}
}
};
socket.onclose = function(event) {
connected = false;
socket = null;
debugLog('WebSocket closed, code:', event.code, 'reason:', event.reason);
// Reconnect after delay
setTimeout(createSocket, RECONNECT_INTERVAL);
};
socket.onerror = function(error) {
connected = false;
debugLog('WebSocket error:', error);
};
}
// Send message to channel
function sendToChannel(event, payload) {
if (connected && socket && socket.readyState === WebSocket.OPEN) {
const msg = JSON.stringify([null, null, CHANNEL_TOPIC, event, payload]);
socket.send(msg);
} else {
// Queue message for when connection is established (with limit)
if (messageQueue.length < config.maxQueueSize) {
messageQueue.push({ event, payload });
}
}
}
// Flush queued messages
function flushQueue() {
while (messageQueue.length > 0) {
const { event, payload } = messageQueue.shift();
sendToChannel(event, payload);
}
}
// Wrap console methods
function wrapConsole() {
config.levels.forEach(method => {
originalConsole[method] = console[method];
console[method] = function(...args) {
// Call original
originalConsole[method].apply(console, args);
// Prevent re-entrancy
if (isForwarding) return;
isForwarding = true;
try {
// Send to server
sendToChannel('log', {
level: method,
args: serialize(args),
url: window.location.href,
timestamp: new Date().toISOString()
});
} finally {
isForwarding = false;
}
};
});
}
// Capture global errors
function captureErrors() {
if (config.captureErrors) {
errorHandler = function(event) {
sendToChannel('error', {
level: 'error',
args: [event.message],
stack: event.error ? event.error.stack : null,
url: event.filename,
line: event.lineno,
column: event.colno
});
};
window.addEventListener('error', errorHandler);
}
if (config.captureRejections) {
rejectionHandler = function(event) {
const reason = event.reason;
let message = 'Unhandled Promise Rejection';
let stack = null;
if (reason instanceof Error) {
message = reason.message;
stack = reason.stack;
} else if (typeof reason === 'string') {
message = reason;
}
sendToChannel('error', {
level: 'error',
args: [`Unhandled Promise Rejection: ${message}`],
stack: stack
});
};
window.addEventListener('unhandledrejection', rejectionHandler);
}
}
// Restore original console (for cleanup/HMR)
function restore() {
Object.keys(originalConsole).forEach(method => {
console[method] = originalConsole[method];
});
if (errorHandler) {
window.removeEventListener('error', errorHandler);
errorHandler = null;
}
if (rejectionHandler) {
window.removeEventListener('unhandledrejection', rejectionHandler);
rejectionHandler = null;
}
if (socket) {
socket.close();
socket = null;
}
connected = false;
messageQueue = [];
}
// Initialize
function init(options = {}) {
// Merge options with defaults
config = { ...defaultOptions, ...options };
// Store original console.log first for debug logging
originalConsole.log = console.log;
debugLog('Initializing...');
wrapConsole();
debugLog('Console methods wrapped');
captureErrors();
debugLog('Error handlers attached');
createSocket();
debugLog('Socket connection initiated to', SOCKET_PATH);
}
// Expose for HMR/cleanup and configuration
window.__phoenixEchoRestore = restore;
window.__phoenixEchoInit = init;
// Only run in development
function isDev() {
// Check for Phoenix LiveReload (only present in dev)
if (document.querySelector('iframe[src*="phoenix/live_reload"]')) return true;
// Check for localhost/common dev hosts
const host = window.location.hostname;
if (host === 'localhost' || host === '127.0.0.1' || host.endsWith('.local')) return true;
// Check for explicit flag
if (window.__PHOENIX_ECHO_ENABLED__) return true;
return false;
}
// Start
if (isDev()) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
}
})();