Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions dev/misc/ws.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>dev - alignment - bam</title>
</head>

<body>

<h2>Test WebSocket interface</h2>

<div id="igvDiv" style="padding-top: 50px;padding-bottom: 20px; height: auto"></div>

<script type="module">

import igv from "../../js/index.js"

const config = {
genome: "hg19",
enableWebSocket: true,
webSocketHost: "localhost",
webSocketPort: 60141
}

await igv.createBrowser(document.getElementById('igvDiv'), config)

</script>

</body>

</html>
8 changes: 7 additions & 1 deletion js/igv-create.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {GoogleAuth, igvxhr} from '../node_modules/igv-utils/src/index.js'
import Browser from "./browser.js"
import GenomeUtils from "./genome/genomeUtils.js"
import InputDialog from "./ui/components/inputDialog.js"
import createWebSocketClient from "./websocket/websocketClient.js"

let allBrowsers = []

Expand Down Expand Up @@ -60,8 +61,13 @@ async function createBrowser(parentDiv, config) {

browser.navbar.navbarDidResize()

return browser
if(config.enableWebSocket) {
const host = config.webSocketHost || "localhost"
const port = config.webSocketPort || 60141
createWebSocketClient(host, port, browser)
}

return browser
}

function removeBrowser(browser) {
Expand Down
4 changes: 3 additions & 1 deletion js/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import AlertDialog from "./ui/components/alertDialog.js"
import {registerFileFormats} from "./util/fileFormats.js"
import {loadHub} from "./ucsc/hub/hubParser.js"
import {createIcon} from "./ui/utils/icons.js"
import createWebSocketClient from "./websocket/websocketClient.js"

const setApiKey = igvxhr.setApiKey

Expand Down Expand Up @@ -56,6 +57,7 @@ export default {
loadSessionFile: Browser.loadSessionFile,
loadHub,
uncompressSession: Browser.uncompressSession,
createIcon
createIcon,
createWebSocketClient
}

2 changes: 1 addition & 1 deletion js/version.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const _version = "3.6.0"
const _version = "3.7.0"
function version() {
return _version
}
Expand Down
177 changes: 177 additions & 0 deletions js/websocket/messageHandler.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
/**
* Handles incoming messages from the WebSocket connection. Performs requested actions on the IGV browser instance
* and returns a response message.
*
* @param json
* @param browser
* @returns {Promise<{uniqueID, message: string, status: string}>}
*/


export default async function handleMessage(json, browser) {

const returnMsg = {uniqueID: json.uniqueID, status: 'ok'}

try {
let tracks
const {type, args} = json
switch (type.toLowerCase()) {

case "goto":
case "search":
const term = args.locus || args.term
const found = await browser.search(term)
if (found) {
returnMsg.message = `Locus ${term} found and navigated to successfully`
} else {
returnMsg.message = `Locus ${term} not found`
returnMsg.status = 'warning'
}
break

case "currentloci":
returnMsg.data = browser.currentLoci()
returnMsg.message = `Retrieved current loci successfully`
break

case "visibilityChange":
returnMsg.message = await browser.visibilityChange()
break

case "tojson":
returnMsg.data = browser.toJSON()
returnMsg.message = `Session serialized to JSON successfully`
break

case "compressedsession":
returnMsg.data = browser.compressedSession()
returnMsg.message = `Session serialized and compressed successfully`
break

case "tosvg":
returnMsg.data = browser.toSVG()
returnMsg.message = `Session exported to SVG successfully`
break

case "removetrackbyname": {
let {trackName} = args
if(trackName) {
tracks = browser.findTracks(t => trackName ? t.name === trackName : true)
if (tracks) {
tracks.forEach(t => browser.removeTrack(t))
returnMsg.message = `Removed track(s) ${trackName} for ${tracks.length} track(s)`
} else {
returnMsg.message = `No tracks found matching name ${trackName}`
returnMsg.status = 'warning'
}
} else {
returnMsg.message = `No track name provided`
returnMsg.status = 'warning'
}
break
}

case "loadsampleinfo": {
browser.loadSampleInfo(args)
returnMsg.message = `Sample info loaded successfully`
break
}

case "discardsampleinfo":
browser.discardSampleInfo()
returnMsg.message = `Sample info discarded successfully`
break

case "loadroi":
browser.loadROI(args)
returnMsg.message = `ROI loaded successfully`
break

case "clearrois":
browser.clearROIs()
returnMsg.message = `ROIs cleared successfully`
break

case "getuserdefinedrois":
const rois = await browser.getUserDefinedROIs()
returnMsg.data = rois
returnMsg.message = `Retrieved ${rois.length} user-defined ROIs successfully`
break

case 'loadtrack': {
const {url, indexURL} = args
const track = await browser.loadTrack({url, indexURL})
returnMsg.message = `Track ${track.name} loaded successfully`
break
}

case "genome":
const id = args.id
await browser.loadGenome(id)
returnMsg.message = `Genome ${id} loaded successfully`
break

case "loadsession":
const url = args.url
await browser.loadSession({url})
returnMsg.message = `Session loaded successfully from ${url}`
break

case "zoomin":
await browser.zoomIn()
returnMsg.message = `Zoomed in successfully`
break

case "zoomout":
await browser.zoomOut()
returnMsg.message = `Zoomed out successfully`
break

case "setcolor":

let {color, trackName} = args

if (color.includes(",") && !color.startsWith("rgb(")) {
// Convert "R,G,B" to "rgb(R,G,B)"
color = `rgb(${color})`
}

tracks = browser.findTracks(t => trackName ? t.name === trackName : true)
if (tracks) {
tracks.forEach(t => t.color = color)
browser.repaintViews()
returnMsg.message = `Set color to ${color} for ${tracks.length} track(s)`
} else {
returnMsg.message = `No tracks found matching name ${trackName}`
returnMsg.status = 'warning'
}
break

case "renametrack":

const {currentName, newName} = args

tracks = browser.findTracks(t => currentName === t.name)
if (tracks && tracks.length > 0) {
tracks.forEach(t => {
t.name = newName
browser.fireEvent('tracknamechange', [t])
})
returnMsg.message = `Renamed ${tracks.length} track(s) from ${currentName} to ${newName}`
} else {
returnMsg.message = `No track found with name ${currentName}`
returnMsg.status = 'warning'
}
break

default:
returnMsg.message = `Unrecognized message type: ${type}`
returnMsg.status = 'error'
}
} catch (err) {
returnMsg.message = err?.message || String(err)
returnMsg.status = 'error'
}

return returnMsg
}
100 changes: 100 additions & 0 deletions js/websocket/websocketClient.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import handleMessage from "./messageHandler.js"

/**
* Create a WebSocket client that connects to a server and handles messages. The client attempts to connect to a
* WebSocketServer upon creation. If the connection is not successful or lost, it will attempt to reconnect with an
* exponential backoff strategy. Incoming messages are expected to be JSON formatted and are processed by the
* handleMessage function. Messages encompass a subset of the igv.js API
*
* This client was created to interact with an MCP server, but could be used for other purposes.
*
* @param host Host for the WebSocket server
* @param port Port for the WebSocket server
* @param browser The igv.js browser instance
*/

export function createWebSocketClient(host, port, browser) {

let socket
let retryInterval = 1000 // Initial retry interval in ms
const maxRetryInterval = 10000 // Maximum retry interval in ms
let reconnectTimer
let intentionalClose = false // Flag to prevent reconnection on intentional close

function connect() {

const isLocal = host === 'localhost' || host === '127.0.0.1'
const protocol = window.location.protocol === 'https:' && !isLocal ? 'wss:' : 'ws:'
socket = new WebSocket(`${protocol}//${host}:${port}`)

// helper to safely send
const sendJSON = (obj) => {
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(obj))
}
}

socket.addEventListener('open', function (event) {
retryInterval = 1000 // Reset retry interval on successful connection
sendJSON({message: 'Hello from browser client'})
})

// Listen for incoming messages
socket.addEventListener('message', async function (event) {
try {
const json = JSON.parse(event.data)

if("close" === json.type) {
intentionalClose = true
clearTimeout(reconnectTimer)
if (socket && socket.readyState === WebSocket.OPEN) {
socket.close()
}
return
}

const returnMsg = await handleMessage(json, browser)
sendJSON(returnMsg)

} catch (e) {
if (e instanceof SyntaxError) {
console.warn('Received non-JSON message from server:', event.data)
} else {
console.error('Error handling message:', e)
sendJSON({
status: 'error',
message: `Error handling message: ${e.message || e.toString()}`
})
}
}
})

socket.addEventListener('error', function (event) {
console.error('WebSocket error:', event)
// The 'close' event will fire immediately after 'error', triggering the reconnect logic.
})

socket.addEventListener('close', function (event) {
if (intentionalClose) {
console.log('WebSocket closed intentionally. Not reconnecting.')
return
}
console.log('Disconnected from server. Retrying in ' + (retryInterval / 1000) + ' seconds.')
clearTimeout(reconnectTimer)
reconnectTimer = setTimeout(connect, retryInterval)
// Increase retry interval for next time, up to a max
retryInterval = Math.min(maxRetryInterval, retryInterval * 2)
})
}

connect() // Initial connection attempt

window.addEventListener('beforeunload', function (event) {
clearTimeout(reconnectTimer) // Don't try to reconnect when page is closing
if (socket && socket.readyState === WebSocket.OPEN) {
socket.close()
}
})
}

export default createWebSocketClient
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "igv",
"version": "3.6.0",
"version": "3.7.0",
"main": "dist/igv.esm.js",
"browser": "dist/igv.js",
"module": "dist/igv.esm.js",
Expand Down Expand Up @@ -63,6 +63,7 @@
"rollup-plugin-copy": "^3.3.0",
"sass": "^1.45.1",
"vanilla-picker": "^2.12.3",
"w3c-xmlhttprequest": "^3.0.0"
"w3c-xmlhttprequest": "^3.0.0",
"ws": "^8.18.3"
}
}
Loading