Whistle Plugin Development

Whistle Plugin Development

9月 9, 2023 · 5 分钟阅读时长 · 2220 字 · -阅读 -评论

Introduction

Whistle is a cross-platform web debugging proxy tool based on Node.js. It supports HTTP, HTTPS, WebSocket protocols and provides rich plugin mechanisms for extending functionality.

Understanding Whistle

Core Features

  • Protocol Support: HTTP/HTTPS/WebSocket/Tunnel
  • Rule Configuration: Flexible rule matching and processing
  • Plugin System: Rich plugin ecosystem
  • Visual Interface: Web-based management interface
  • Multi-platform: Cross-platform support

Basic Usage

Install and start whistle:

# Install globally
npm install -g whistle

# Start whistle
w2 start

# Access web interface
# http://localhost:8899

Plugin Development Basics

Plugin Structure

A typical whistle plugin structure:

whistle.your-plugin/
├── package.json
├── index.js
├── lib/
│   ├── server.js
│   └── utils.js
├── public/
│   ├── index.html
│   ├── index.js
│   └── index.css
└── README.md

Package.json Configuration

{
  "name": "whistle.your-plugin",
  "version": "1.0.0",
  "description": "Your whistle plugin description",
  "main": "index.js",
  "scripts": {
    "start": "node index.js"
  },
  "keywords": ["whistle", "plugin"],
  "whistleConfig": {
    "hintList": ["your-plugin://"]
  }
}

Entry Point (index.js)

module.exports = (server, options) => {
  // Plugin initialization logic
  console.log('Plugin loaded:', options);
  
  // Return plugin handler
  return {
    // Request handler
    requestHandler: (req, res, next) => {
      // Process request
      next();
    },
    
    // Response handler
    responseHandler: (req, res, next) => {
      // Process response
      next();
    },
    
    // WebSocket handler
    wsHandler: (ws, req) => {
      // Handle WebSocket connections
    }
  };
};

Plugin Types

1. Protocol Plugin

Implement custom protocols:

// whistle.myprotocol
module.exports = (server, options) => {
  server.on('request', (req, res) => {
    const { originalReq } = req;
    const protocol = originalReq.headers['x-whistle-rule-value'];
    
    if (protocol === 'myprotocol://mock') {
      res.end(JSON.stringify({
        message: 'Mock response from custom protocol'
      }));
    }
  });
};

Usage in whistle rules:

example.com myprotocol://mock

2. UI Plugin

Create management interfaces:

// lib/server.js
const express = require('express');
const path = require('path');

module.exports = (server, options) => {
  const app = express();
  
  // Serve static files
  app.use(express.static(path.join(__dirname, '../public')));
  
  // API endpoints
  app.get('/api/config', (req, res) => {
    res.json({ status: 'ok' });
  });
  
  app.post('/api/config', (req, res) => {
    // Handle configuration updates
    res.json({ success: true });
  });
  
  return app;
};

3. Inspection Plugin

Analyze and modify requests/responses:

module.exports = (server, options) => {
  const inspectedRequests = [];
  
  server.on('request', (req, res) => {
    const startTime = Date.now();
    
    // Capture request data
    const requestData = {
      url: req.originalReq.url,
      method: req.originalReq.method,
      headers: req.originalReq.headers,
      timestamp: startTime
    };
    
    // Capture response data
    const originalEnd = res.end;
    res.end = function(chunk, encoding) {
      requestData.responseTime = Date.now() - startTime;
      requestData.statusCode = res.statusCode;
      
      inspectedRequests.push(requestData);
      
      // Call original end method
      originalEnd.call(this, chunk, encoding);
    };
  });
  
  // Provide API to access captured data
  server.on('upgrade', (req, socket, head) => {
    if (req.url === '/ws/inspect') {
      // WebSocket connection for real-time data
    }
  });
};

Advanced Features

Request/Response Modification

module.exports = (server, options) => {
  return {
    requestHandler: (req, res, next) => {
      // Modify request headers
      req.headers['x-custom-header'] = 'modified';
      
      // Modify request body
      if (req.method === 'POST') {
        let body = '';
        req.on('data', chunk => {
          body += chunk.toString();
        });
        
        req.on('end', () => {
          try {
            const data = JSON.parse(body);
            data.modified = true;
            
            const newBody = JSON.stringify(data);
            req.headers['content-length'] = Buffer.byteLength(newBody);
            
            // Continue with modified body
            next();
          } catch (e) {
            next();
          }
        });
      } else {
        next();
      }
    },
    
    responseHandler: (req, res, next) => {
      // Modify response
      const originalWrite = res.write;
      const originalEnd = res.end;
      
      res.write = function(chunk, encoding) {
        // Modify response data
        if (typeof chunk === 'string') {
          chunk = chunk.replace(/error/g, 'success');
        }
        return originalWrite.call(this, chunk, encoding);
      };
      
      next();
    }
  };
};

Configuration Management

const fs = require('fs');
const path = require('path');

class ConfigManager {
  constructor(configPath) {
    this.configPath = configPath;
    this.config = this.loadConfig();
  }
  
  loadConfig() {
    try {
      if (fs.existsSync(this.configPath)) {
        return JSON.parse(fs.readFileSync(this.configPath, 'utf8'));
      }
    } catch (e) {
      console.error('Failed to load config:', e);
    }
    return {};
  }
  
  saveConfig() {
    try {
      fs.writeFileSync(this.configPath, JSON.stringify(this.config, null, 2));
    } catch (e) {
      console.error('Failed to save config:', e);
    }
  }
  
  get(key) {
    return this.config[key];
  }
  
  set(key, value) {
    this.config[key] = value;
    this.saveConfig();
  }
}

module.exports = (server, options) => {
  const configManager = new ConfigManager(
    path.join(options.storage, 'plugin-config.json')
  );
  
  // Use configuration in plugin logic
  return {
    configManager,
    // ... other handlers
  };
};

Testing and Debugging

Unit Testing

// test/plugin.test.js
const assert = require('assert');
const plugin = require('../index');

describe('Plugin Tests', () => {
  it('should handle requests correctly', (done) => {
    const mockServer = {
      on: (event, handler) => {
        if (event === 'request') {
          // Test request handling
          const mockReq = { originalReq: { url: '/test' } };
          const mockRes = { end: (data) => {
            assert(data.includes('test'));
            done();
          }};
          handler(mockReq, mockRes);
        }
      }
    };
    
    plugin(mockServer, {});
  });
});

Debug Mode

const debug = require('debug')('whistle:plugin:yourname');

module.exports = (server, options) => {
  debug('Plugin loaded with options:', options);
  
  return {
    requestHandler: (req, res, next) => {
      debug('Processing request:', req.originalReq.url);
      next();
    }
  };
};

Publishing and Distribution

NPM Publishing

# Prepare package
npm version patch
npm publish

# Install in whistle
npm install -g whistle.your-plugin

Local Development

# Link for development
npm link
cd whistle-project
npm link whistle.your-plugin

# Restart whistle to load plugin
w2 restart

Best Practices

1. Error Handling

module.exports = (server, options) => {
  return {
    requestHandler: (req, res, next) => {
      try {
        // Plugin logic
        next();
      } catch (error) {
        console.error('Plugin error:', error);
        next(); // Always call next to avoid hanging
      }
    }
  };
};

2. Performance Optimization

// Use streams for large data
const stream = require('stream');

class ResponseTransform extends stream.Transform {
  _transform(chunk, encoding, callback) {
    // Transform response data efficiently
    const transformed = chunk.toString().replace(/old/g, 'new');
    callback(null, transformed);
  }
}

3. Security Considerations

// Validate and sanitize inputs
function sanitizeInput(input) {
  return input.replace(/[<>]/g, '');
}

// Limit resource usage
const requestCount = new Map();
function rateLimitCheck(ip) {
  const count = requestCount.get(ip) || 0;
  if (count > 100) {
    return false; // Rate limited
  }
  requestCount.set(ip, count + 1);
  return true;
}

Real-world Examples

Mock Server Plugin

module.exports = (server, options) => {
  const mocks = new Map();
  
  server.on('request', (req, res) => {
    const { originalReq } = req;
    const mockKey = `${originalReq.method}:${originalReq.url}`;
    
    if (mocks.has(mockKey)) {
      const mockResponse = mocks.get(mockKey);
      res.writeHead(mockResponse.statusCode, mockResponse.headers);
      res.end(mockResponse.body);
    } else {
      // Forward to actual server
      req.passThrough();
    }
  });
  
  // Management API
  return {
    addMock: (method, url, response) => {
      mocks.set(`${method}:${url}`, response);
    },
    removeMock: (method, url) => {
      mocks.delete(`${method}:${url}`);
    }
  };
};

Conclusion

Whistle plugin development provides powerful capabilities for network debugging and proxy customization. Key points to remember:

  1. Understand the Plugin Types: Choose the right type for your use case
  2. Follow Conventions: Use standard naming and structure
  3. Handle Errors Gracefully: Ensure plugins don’t break whistle
  4. Test Thoroughly: Test with various scenarios
  5. Document Well: Provide clear usage instructions

Start with simple plugins and gradually build more complex functionality as you become familiar with the whistle plugin system.

最近公司提供了云IDE服务,这样部分开发工作可以从本地切换到远程机器,同时现在前端也走向了微前端,开发时总是需要走代理。云IDE出现后,意味着代理的地址会随着IDE地址而变化,因此这里驱动出了,解决一键加载远程代理规则的需求。

云IDE出现后,整体开发流程如下

  1. 当任务管理系统下发一个story
  2. 开发者创建feat/fix分支,CI自动创建一台云IDE开发环境,同时创建出新的whistle代理规则文件
  3. CI发送消息告知云IDE地址及whistle代理规则文件地址
  4. 开发者点击whistle代理地址,直接加载该规则
  5. 开发者开始开发

如上的好处是,开发人员不用手动维护代理规则文件。

为了解决这样的需求,开始whistle插件开发,发现whistle提供了脚手架工具及一些demo,整体开发还是比较顺利的。但有些问题,官网并没有描述很清楚,这里简单总结下,兴许帮到些同行。

开发

大致实现逻辑如下

  1. 利用UI Server针对whistle web服务暴露新的URL,支持携带remoteRules参数
  2. 插件UI router中处理参数,根据参数值下载rule规则文件,临时保存
  3. 调用whistle命令w2 add进行加载
  4. 重定向到whistle web rules页面下

插件安装及源码戳这里,兴许帮到些人

使用效果

$ npm i whistle -g

$ npm i whistle.remote-rules -g 
# or

$ w2 i whistle.remote-rules

自动加载远程规则,地址模版如下

http://local.whistlejs.com/whistle.remote-rules/?remoteRules={url}

举个例子

http://local.whistlejs.com/whistle.remote-rules/?remoteRules=https%3A%2F%2Fgist.githubusercontent.com%2Falanhg%2Fa950d216121002d5bb0fd1278789da75%2Fraw%2Fc8bdf23e05c9dc504ea393a0b373cb556df911d8%2Ftest-whistle.js

有了该插件支持,CI方面就可以直接推送如上的地址,开发者点击加载后就可以继续开发调试了。

注意事项

whistle官方文档及案例有些方面描述的不够清晰,这里补充下

  1. 插件调试

    # 如果whistle处于运行状态下,需要手动关闭
    $ w2 stop
    
    # 运行whistle,这时是调试模式,插件脚本中的异常或者console打印信息均会在脚本启动的终端打印出来
    $ w2 run
    
  2. 插件开发,热更

    # 脚本修改,实时编译,TS开发时需要
    $ npm run dev
    
    # 监测dist及rules文件,如果有修复会自动更新加载的插件,如果更新,终端会打印reload信息
    $ lack watch
    
  3. 发布

    插件发布,并不需要推送到whistle作者的repo或者whistle-plugins组织下,以个人名义发布即可,只需要注意,whistle约定包名称来确保被whistle识别,比如我开发的whistle.remote-rules

  4. whistle-server

    whistle提供了多种server,确定需求从而选择对应的server,比如需要拓展UI服务,实现自定义页面来执行某个动作,比如我这里的暴露URL,实现自动下载远程规则,所以依赖uiServer,而router对应就是自己实现的一些API或者子路由页面

写在最后

Whistle暴露的口子支持基于个人需求可以拓展代理feat,这点还是很赞的。

Alan H
Authors
开发者,数码产品爱好者,喜欢折腾,喜欢分享,喜欢开源