UNPKG

@swell/cli

Version:

Swell's command line interface/utility

155 lines (154 loc) 5.77 kB
import getPort, { portNumbers } from 'get-port'; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - http-proxy has incomplete TypeScript definitions import httpProxy from 'http-proxy'; import http from 'node:http'; import util from 'node:util'; import zlib from 'node:zlib'; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - ws library uses named exports differently // eslint-disable-next-line import/no-named-as-default import WebSocket, { WebSocketServer } from 'ws'; const gunzipAsync = util.promisify(zlib.gunzip); const inflateAsync = util.promisify(zlib.inflate); const brotliDecompressAsync = util.promisify(zlib.brotliDecompress); const FALLBACK_PORT = 3000; export class ThemeSync { options; proxy; server; wss; constructor(options) { this.options = options; this.server = http.createServer(); this.proxy = httpProxy.createProxyServer({}); this.wss = new WebSocketServer({ server: this.server }); } reload() { // Get all connected WebSocket clients const clients = [...this.wss.clients].filter((client) => client.readyState === WebSocket.OPEN); // Notify clients to reload for (const client of clients) { client.send('reload'); } } async start() { // Select port if not specified if (!this.options.port) { this.options.port = await this.selectPort(); } return new Promise((resolve, reject) => { // Create proxy server this.server.on('request', (req, res) => { this.proxy.web(req, res, { target: this.options.targetUrl, changeOrigin: true, selfHandleResponse: true, }, (err) => { if (err) { console.error('Proxy error:', err); if (res instanceof http.ServerResponse) { res.writeHead(500, { 'Content-Type': 'text/plain' }); res.end('Proxy error'); } } }); }); this.proxy.on('proxyReq', (proxyReq) => { proxyReq.setHeader('swell-request-id', `${Date.now()}`); }); this.proxy.on('proxyRes', (proxyRes, req, res) => { let body = Buffer.from(''); proxyRes.on('data', (chunk) => { body = Buffer.concat([body, chunk]); }); // Preserve headers for (let i = 0; i < proxyRes.rawHeaders.length; i += 2) { const header = proxyRes.rawHeaders[i]; res.setHeader(header, proxyRes.rawHeaders[i + 1]); } // Use utf8 encoding for the response res.setHeader('Content-Encoding', 'utf8'); proxyRes.on('end', async () => { const html = await this.parseRequestBody(proxyRes, body); if (this.shouldInjectScript(proxyRes, html)) { const modifiedHtml = this.injectScript(html, `localhost:${this.options.port}`); res.end(modifiedHtml); } else { res.end(html); } }); }); // Handle proxy errors this.proxy.on('error', (err) => { console.error('Proxy error:', err); }); // Start server this.server.listen(this.options.port, () => { resolve(this.options.port); }); this.server.on('error', (err) => { reject(err); }); }); } decodeRequestBody(body, encoding) { switch (encoding) { case 'gzip': { return gunzipAsync(body); } case 'deflate': { return inflateAsync(body); } case 'br': { return brotliDecompressAsync(body); } default: { return Promise.reject(new Error(`Unsupported content encoding: ${encoding}`)); } } } // Inject WebSocket client script into proxied HTML injectScript(html, host) { const script = ` <script> const ws = new WebSocket('ws://${host}/ws'); ws.onmessage = () => { window.location.reload(); }; </script> `; return html.replace('</body>', `${script}</body>`); } async parseRequestBody(proxyRes, body) { // May contain multiple encodings const contentEncoding = String(proxyRes.headers['content-encoding'] ?? '') .split(',') .reverse(); // Sequential decoding of content-encoding layers is required /* eslint-disable no-await-in-loop */ for (const value of contentEncoding) { const encoding = value.trim().toLowerCase(); if (encoding) { body = await this.decodeRequestBody(body, encoding); } } /* eslint-enable no-await-in-loop */ return body.toString('utf8'); } async selectPort() { return (await getPort({ port: portNumbers(3000, 3100) })) || FALLBACK_PORT; } shouldInjectScript(proxyRes, html) { // Check if this is an HTML response const contentType = proxyRes.headers['content-type']; if (!contentType?.includes('text/html')) { return false; } if (!html.includes('</body>')) { return false; } return true; } }