extension-develop
Version:
The develop step of Extension.js
325 lines (281 loc) • 8.84 kB
JavaScript
const TEN_SECONDS_MS = 10 * 1000
const INSTANT_MS = 100
let webSocket = null
export async function connect() {
if (webSocket) {
// If already connected, do nothing
return
}
// Get port from the placeholder that will be replaced during build
const port = '__RELOAD_PORT__'
let reconnectAttempts = 0
const maxReconnectAttempts = 10
const baseBackoffMs = 250
const maxBackoffMs = 5000
const establish = () => {
try {
webSocket = new WebSocket(`ws://127.0.0.1:${port}`)
} catch (err) {
webSocket = null
return
}
webSocket.onerror = (_event) => {
try {
webSocket && webSocket.close()
} catch {}
}
webSocket.onopen = () => {
reconnectAttempts = 0
console.info(
`[Extension.js] Connection opened. Listening on port ${port}...`
)
}
let reloadDebounce
webSocket.onmessage = async (event) => {
let message = null
try {
message = JSON.parse(event.data)
} catch {
return
}
if (message.status === 'serverReady') {
await requestInitialLoadData()
}
if (message.changedFile) {
clearTimeout(reloadDebounce)
reloadDebounce = setTimeout(async () => {
await hardReloadAllExtensions(message.changedFile)
}, 200)
}
}
webSocket.onclose = () => {
webSocket = null
if (reconnectAttempts >= maxReconnectAttempts) return
reconnectAttempts++
const backoff = Math.min(
maxBackoffMs,
baseBackoffMs * 2 ** reconnectAttempts
)
setTimeout(establish, backoff)
}
}
establish()
}
export function disconnect() {
if (webSocket) {
webSocket.close()
}
}
// Ensure sockets are closed when the worker is suspended
try {
chrome.runtime.onSuspend.addListener(() => {
if (webSocket) {
try {
webSocket.close()
} catch {}
webSocket = null
}
})
} catch {}
async function getDevExtensions() {
const allExtensions = await new Promise((resolve) => {
chrome.management.getAll(resolve)
})
return allExtensions.filter((extension) => {
return (
// Do not include itself
extension.id !== chrome.runtime.id &&
// Manager extension
extension.id !== 'hkklidinfhnfidkjiknmmbmcloigimco' &&
// Show only unpackaged extensions
extension.installType === 'development'
)
})
}
async function hardReloadAllExtensions(changedFile) {
// For critical files like manifest.json, skip the delay entirely
const isCriticalFile =
changedFile === 'manifest.json' ||
changedFile === 'service_worker' ||
changedFile === 'declarative_net_request'
// Check if the external extension is ready with optimized timing
const isExtensionReady = await checkExtensionReadiness(isCriticalFile)
if (isExtensionReady) {
const devExtensions = await getDevExtensions()
const reloadAll = devExtensions.map(async (extension) => {
await hardReloadExtension(extension.id)
chrome.runtime.sendMessage(extension.id, {changedFile}, (response) => {
if (response) {
console.info('[Reload Service] Extension reloaded and ready.')
}
})
return true
})
await Promise.all(reloadAll)
} else {
console.info('[Reload Service] External extension is not ready.')
}
}
async function requestInitialLoadData() {
const devExtensions = await getDevExtensions()
const messagePromises = devExtensions.map(async (extension) => {
return await new Promise((resolve) => {
chrome.runtime.sendMessage(
extension.id,
{initialLoadData: true},
(response) => {
if (chrome.runtime.lastError) {
console.error(
`Error sending message to ${extension.id}: ${chrome.runtime.lastError.message}`
)
resolve(null)
} else {
resolve(response)
}
}
)
})
})
const responses = await Promise.all(messagePromises)
// We received the info from the use extension.
// All good, client is ready. Inform the server.
if (webSocket && webSocket.readyState === WebSocket.OPEN) {
const message = JSON.stringify({
status: 'clientReady',
data: responses[0]
})
webSocket.send(message)
}
}
async function checkExtensionReadiness(isCriticalFile = false) {
// For critical files like manifest.json, use minimal delay
// For regular files, use 1 second delay to ensure stability
const delayTime = isCriticalFile ? INSTANT_MS : 1000
await delay(delayTime)
// Assume the extension is ready
return true
}
async function delay(ms) {
return await new Promise((resolve) => setTimeout(resolve, ms))
}
export function keepAlive() {
const keepAliveIntervalId = setInterval(() => {
if (webSocket) {
webSocket.send(JSON.stringify({status: 'ping'}))
console.info('[Reload Service] Listening for changes...')
} else {
clearInterval(keepAliveIntervalId)
}
}, TEN_SECONDS_MS)
}
const toggleExtensionState = (extensionId, enabled) => {
if (extensionId === chrome.runtime.id) {
return Promise.resolve()
}
return new Promise((resolve) => {
chrome.management.setEnabled(extensionId, enabled, resolve)
})
}
// Smart reload strategy: Try standard method first, fallback to CDP if needed
async function hardReloadExtension(extensionId) {
try {
// Try the standard enable/disable reload first (feels natural)
await toggleExtensionState(extensionId, false)
await toggleExtensionState(extensionId, true)
// Verify it worked with a simple health check
const isHealthy = await verifyExtensionHealth(extensionId)
if (isHealthy) {
console.info(
`[Extension.js] Standard reload successful for ${extensionId}`
)
return true
}
// If we get here, the reload "succeeded" but extension isn't healthy
throw new Error('Extension reloaded but not responding')
} catch (error) {
console.warn(
`[Extension.js] Standard reload failed for ${extensionId}, trying CDP fallback: ${error.message}`
)
// Only use CDP when the natural approach fails
return await attemptCDPFallback(extensionId)
}
}
// CDP fallback for when standard reload fails
async function attemptCDPFallback(extensionId) {
try {
// Get extension info to find its source path
const extensionInfo = await new Promise((resolve) => {
chrome.management.get(extensionId, resolve)
})
if (!extensionInfo || !extensionInfo.path) {
console.warn(
`[Extension.js] No path info for ${extensionId}, CDP fallback not possible`
)
return false
}
// Use CDP client directly for seamless fallback
const cdpClient = await getCDPClient()
if (!cdpClient) {
console.warn(`[Extension.js] CDP client not available for ${extensionId}`)
return false
}
// Use CDP to force reload the extension
const success = await cdpClient.forceReloadExtension(extensionId)
if (success) {
console.info(`[Extension.js] CDP fallback successful for ${extensionId}`)
return true
} else {
console.warn(`[Extension.js] CDP fallback failed for ${extensionId}`)
return false
}
} catch (cdpError) {
console.error(
`[Extension.js] CDP fallback also failed for ${extensionId}: ${cdpError.message}`
)
return false
}
}
// Get CDP client instance
// This will be injected by the build process
async function getCDPClient() {
try {
// The CDP client will be available globally when injected
if (typeof window !== 'undefined' && window.cdpClient) {
return window.cdpClient
}
// Fallback for service worker context
if (typeof globalThis !== 'undefined' && globalThis.cdpClient) {
return globalThis.cdpClient
}
console.warn(`[Extension.js] CDP client not found in global scope`)
return null
} catch (error) {
console.warn(`[Extension.js] Failed to get CDP client: ${error.message}`)
return null
}
}
// Simple health check to verify extension is responsive
async function verifyExtensionHealth(extensionId) {
try {
// Simple ping to see if extension responds
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Health check timeout'))
}, 2000)
chrome.runtime.sendMessage(extensionId, {type: 'PING'}, (response) => {
clearTimeout(timeout)
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message))
} else {
resolve(response)
}
})
})
return true
} catch (error) {
console.warn(
`[Extension.js] Health check failed for ${extensionId}: ${error.message}`
)
return false
}
}