@swell/cli
Version:
Swell's command line interface/utility
155 lines (154 loc) • 5.77 kB
JavaScript
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;
}
}