@swell/cli
Version:
Swell's command line interface/utility
145 lines (144 loc) • 5.27 kB
JavaScript
import http from 'http';
import getPort, { portNumbers } from 'get-port';
import util from 'node:util';
import zlib from 'node:zlib';
// @ts-ignore
import httpProxy from 'http-proxy';
// @ts-ignore
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 {
server;
proxy;
wss;
options;
async selectPort() {
return (await getPort({ port: portNumbers(3000, 3100) })) || FALLBACK_PORT;
}
constructor(options) {
this.options = options;
this.server = http.createServer();
this.proxy = httpProxy.createProxyServer({});
this.wss = new WebSocketServer({ server: this.server });
}
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);
});
});
}
reload() {
// Get all connected WebSocket clients
const clients = Array.from(this.wss.clients).filter((client) => client.readyState === WebSocket.OPEN);
// Notify clients to reload
clients.forEach((client) => {
client.send('reload');
});
}
async parseRequestBody(proxyRes, body) {
// May contain multiple encodings
const contentEncoding = String(proxyRes.headers['content-encoding'] ?? '')
.split(',')
.reverse();
for (const value of contentEncoding) {
const encoding = value.trim().toLowerCase();
if (encoding) {
body = await this.decodeRequestBody(body, encoding);
}
}
return body.toString('utf8');
}
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}`));
}
}
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;
}
// 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>`);
}
}