/* eslint-env browser */ import http from 'http' import { WebSocketServer } from 'ws' import { Session } from 'pear-inspect' import b4a from 'b4a' import { spawn } from 'child_process' customElements.define('developer-tooling', class extends HTMLElement { router = null port = 9229 constructor () { super() this.template = document.createElement('template') this.template.innerHTML = `

Developer Tooling

Remotely inspect Pear applications.

Some application setup is required to enable remote debugging

Install the pear-inspect module into the application

npm install pear-inspect

Add the following code to the application, before any other code:

if (Pear.config.dev) {
  const { Inspector } = await import('pear-inspect')
  const inpector = await new Inspector()
  const key = await inpector.enable()
  console.log('Debug with pear://runtime/devtools/'
    + key.toString('hex'))
}

When the application is opened in development mode the inspection key will be logged.

Paste the logged key into the input to add it. Then open in Chrome or copy the URL to a compatible inspect tool to view the remote target.

Apps

No apps added. Add an inspect key to start debugging.

` this.root = this.attachShadow({ mode: 'open' }) this.root.appendChild(this.template.content.cloneNode(true)) this.addKeyFormElem = this.root.querySelector('#add-key-form') this.addKeyInputElem = this.root.querySelector('#add-key-input') this.addKeyErrorElem = this.root.querySelector('#add-key-error') this.changePortFormElem = this.root.querySelector('#change-port-form') this.changePortInputElem = this.root.querySelector('#change-port-input') this.changePortErrorElem = this.root.querySelector('#change-port-error') this.changePortShowElem = this.root.querySelector('#change-port-show') this.appsElem = this.root.querySelector('#apps') this.noAppsElem = this.root.querySelector('#no-apps') this.changePortElem = this.root.querySelector('#change-port') this.apps = new Map() this.addKeyInputElem.addEventListener('keydown', e => { this.addKeyErrorElem.textContent = '' }) this.addKeyFormElem.addEventListener('submit', e => { e.preventDefault() const inspectorKey = this.addKeyInputElem.value this.addApp(inspectorKey) }) this.changePortInputElem.addEventListener('keydown', e => { const isEscape = e.key === 'Escape' this.changePortErrorElem.textContent = '' if (isEscape) { this.changePortFormElem.classList.add('hidden') this.changePortInputElem.value = '' } }) this.changePortShowElem.addEventListener('click', () => { this.changePortFormElem.classList.remove('hidden') }) this.changePortFormElem.addEventListener('submit', e => { e.preventDefault() this.port = Number(this.changePortInputElem.value) this.initServer() }) const shouldLoadApp = Pear.config.linkData?.startsWith('devtools/') if (shouldLoadApp) { this.addApp(Pear.config.linkData) } this.initServer() } render () { this.appsElem.replaceChildren(...[...this.apps].map(([sessionId, app]) => { const div = document.createElement('div') div.innerHTML = `
${app.title} (${app.url})
Copy URL
Open in Chrome
` div.querySelector('.copy').addEventListener('click', () => { navigator.clipboard.writeText(`devtools://devtools/bundled/js_app.html?experiments=true&v8only=true&ws=127.0.0.1:${this.port}/${sessionId}`) }) div.querySelector('.open-in-chrome').addEventListener('click', () => { openChrome(`devtools://devtools/bundled/js_app.html?experiments=true&v8only=true&ws=127.0.0.1:${this.port}/${sessionId}`) }) div.querySelector('.remove').addEventListener('click', () => { this.apps.delete(sessionId) this.render() }) return div })) if (this.apps.size > 0) { this.noAppsElem.classList.add('hidden') } else { this.noAppsElem.classList.remove('hidden') } if (this.port) { this.root.querySelector('#server-location').textContent = `http://localhost:${this.port}` this.root.querySelector('#server-message').classList.remove('hidden') } } addApp (inspectorKey) { const isUrl = inspectorKey.includes('devtools/') if (isUrl) inspectorKey = inspectorKey.split('devtools/').pop() const isIncorrectLength = inspectorKey.length !== 64 if (isIncorrectLength) { this.addKeyErrorElem.textContent = 'Key needs to be 64 characters long' return } const sessionId = generateUuid() const inspectorSession = new Session({ inspectorKey: b4a.from(inspectorKey, 'hex') }) const app = { inspectorKey, title: '', url: '', inspectorSession } inspectorSession.on('close', () => { this.apps.delete(sessionId) this.render() }) inspectorSession.on('info', ({ filename }) => { app.url = filename app.title = filename.split('/').pop() this.apps.set(sessionId, app) this.render() }) this.addKeyInputElem.value = '' this.addKeyErrorElem.textContent = '' this.render() } initServer () { this.devtoolsHttpServer?.close() this.devtoolsHttpServer = http.createServer() const devToolsWsServer = new WebSocketServer({ noServer: true }) this.devtoolsHttpServer.listen(this.port, () => { console.log(`[devtoolsHttpServer] running on port ${this.port}`) this.render() this.changePortFormElem.classList.add('hidden') }) this.devtoolsHttpServer.on('error', err => this.changePortErrorElem.textContent = err?.message) this.devtoolsHttpServer.on('request', (req, res) => { if (req.url !== '/json/list') { res.writeHead(404) res.end() return } const targets = [...this.apps].map(([sessionId, app]) => ({ description: 'node.js instance', // `Pear app: ${app.name}`, devtoolsFrontendUrl: `devtools://devtools/bundled/js_app.html?experiments=true&v8only=true&ws=127.0.0.1:${this.port}/${sessionId}`, devtoolsFrontendUrlCompat: `devtools://devtools/bundled/inspector.html?experiments=true&v8only=true&ws=127.0.0.1:${this.port}/${sessionId}`, faviconUrl: 'https://nodejs.org/static/images/favicons/favicon.ico', id: sessionId, title: app.title, type: 'node', url: `file://${app.url}`, webSocketDebuggerUrl: `ws://127.0.0.1:${this.port}/${sessionId}` })) res.writeHead(200, { 'Content-Type': 'application/json; charset=UTF-8', 'Cache-Control': 'no-cache', 'Content-Length': JSON.stringify(targets).length }) res.end(JSON.stringify(targets)) }) this.devtoolsHttpServer.on('upgrade', (request, socket, head) => { console.log(`[devtoolsHttpServer] UPGRADE. url=${request.url}`) const sessionId = request.url.substr(1) const sessionIdExists = this.apps.has(sessionId) if (!sessionIdExists) return socket.destroy() devToolsWsServer.handleUpgrade(request, socket, head, ws => devToolsWsServer.emit('connection', ws, request)) }) devToolsWsServer.on('connection', (devtoolsSocket, request) => { const sessionId = request.url.substr(1) const app = this.apps.get(sessionId) if (!app) return devtoolsSocket.destroy() const { inspectorSession } = app const onMessage = msg => { const { pearInspectMethod } = msg const isACDPMessage = !pearInspectMethod if (isACDPMessage) return devtoolsSocket.send(JSON.stringify(msg)) } inspectorSession.connect() inspectorSession.on('message', onMessage) devtoolsSocket.on('message', msg => inspectorSession.post(JSON.parse(msg))) devtoolsSocket.on('close', () => { inspectorSession.disconnect() inspectorSession.off('message', onMessage) app.connected = false this.render() }) app.connected = true this.render() }) } load () { this.style.display = '' } unload () { this.style.display = 'none' } }) // Can't use `uuid` module for some reason as it results in a throw with `crypto` when importing function generateUuid () { let result, i, j result = '' for (j = 0; j < 32; j++) { if (j === 8 || j === 12 || j === 16 || j === 20) { result = result + '-' } i = Math.floor(Math.random() * 16).toString(16) result = result + i } return result } function openChrome (url) { const params = { darwin: ['open', '-a', 'Google Chrome', url], linux: ['google-chrome', url], win32: ['start', 'chrome', url] }[process.platform] if (!params) throw new Error('Cannot open Chrome') const [command, ...args] = params spawn(command, args) }