local-traffic 0.0.61 → 0.0.63

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,7 +5,7 @@ That is a secure http/2 (or insecure http1.1) reverse-proxy installed on your ma
5
5
  - with 0 transitive dependency
6
6
  - with 1 install step
7
7
  - with a startup time of a few milliseconds
8
- - with one 26kb index.js file
8
+ - with one 30kb index.js file
9
9
 
10
10
  How simple is that ?
11
11
 
@@ -30,6 +30,7 @@ npx local-traffic
30
30
  "mapping": {
31
31
  "/npm/": "https://www.npmjs.com/",
32
32
  "/my-static-webapp/": "file:///home/user/projects/my-static-webapp/",
33
+ "/config/": "config://",
33
34
  "/logs/": "logs://",
34
35
  "/jquery-local/jquery.js": {
35
36
  "replaceBody": "https://mycdn.net/jquery/jquery-3.6.4.js",
@@ -46,8 +47,9 @@ npx local-traffic
46
47
  3. Go to [http://localhost:8080/npm/](http://localhost:8080/npm) with your browser
47
48
  4. Go to [http://localhost:8080/my-static-webapp/index.html](http://localhost:8080/my-static-webapp/index.html) with your browser (given your project name is my-static-webapp, but I am not 100% sure)
48
49
  5. Go to [http://localhost:8080/logs/](http://localhost:8080/logs/) to watch the request logs
49
- 6. Your page will use /jquery-local/jquery.js instead of the CDN asset, and will serve the file from your hard drive
50
- 7. Your server now proxies the mapping that you have configured
50
+ 6. Go to [http://localhost:8080/config/](http://localhost:8080/config/) to change the config in a web editor
51
+ 7. Your page will use /jquery-local/jquery.js instead of the CDN asset, and will serve the file from your hard drive
52
+ 8. Your server now proxies the mapping that you have configured
51
53
 
52
54
  ## usage
53
55
 
@@ -1,2 +1,2 @@
1
1
  #!/usr/bin/env node
2
- "use strict";var e=this&&this.__awaiter||function(e,t,n,o){return new(n||(n=Promise))((function(r,s){function a(e){try{l(o.next(e))}catch(e){s(e)}}function i(e){try{l(o.throw(e))}catch(e){s(e)}}function l(e){var t;e.done?r(e.value):(t=e.value,t instanceof n?t:new n((function(e){e(t)}))).then(a,i)}l((o=o.apply(e,t||[])).next())}))};Object.defineProperty(exports,"__esModule",{value:!0});const t=require("http2"),n=require("http"),o=require("https"),r=require("url"),s=require("fs"),a=require("zlib"),i=require("path"),l=require("crypto"),c=require("process");var d,p,u;!function(e){e[e.ERROR=124]="ERROR",e[e.INFO=93]="INFO",e[e.WARNING=172]="WARNING"}(d||(d={})),function(e){e.INBOUND="↘️ ",e.PORT="☎️ ",e.OUTBOUND="↗️ ",e.RULES="🔗",e.REWRITE="✒️ ",e.RESTART="🔄",e.WEBSOCKET="☄️ ",e.COLORED="✨",e.SHIELD="🛡️ ",e.NO="⛔",e.ERROR_1="❌",e.ERROR_2="⛈️ ",e.ERROR_3="☢️ ",e.ERROR_4="⁉️ ",e.ERROR_5="⚡",e.ERROR_6="☠️ "}(p||(p={})),function(e){e.INBOUND="INBOUND",e.OUTBOUND="OUTBOUND"}(u||(u={}));const h=(0,i.resolve)(process.env.HOME,".local-traffic.json"),m=(0,i.resolve)(process.cwd(),process.argv.slice(-1)[0].endsWith(".json")?process.argv.slice(-1)[0]:h),f={mapping:{"/logs/":"logs://"},port:8080,replaceRequestBodyUrls:!1,replaceResponseBodyUrls:!1,dontUseHttp2Downstream:!1,dontTranslateLocationHeader:!1,simpleLogs:!1,websocket:!0,disableWebSecurity:!1};let g,y,v=[];const b=e=>e===d.ERROR?"error":e===d.WARNING?"warning":"info",R=(e,t,n)=>{const o=(null==g?void 0:g.simpleLogs)||v.length?e.replace(/⎸/g,"|").replace(/⎹/g,"|").replace(/\u001b\[[^m]*m/g,"").replace(new RegExp(p.INBOUND,"g"),"inbound:").replace(new RegExp(p.PORT,"g"),"port:").replace(new RegExp(p.OUTBOUND,"g"),"outbound:").replace(new RegExp(p.RULES,"g"),"rules:").replace(new RegExp(p.NO,"g"),"").replace(new RegExp(p.REWRITE,"g"),"+rewrite").replace(new RegExp(p.WEBSOCKET,"g"),"websocket").replace(new RegExp(p.SHIELD,"g"),"web-security").replace(/\|+/g,"|"):e;console.log(`${(e=>{const t=new Date;return`${e?"":""}${`${t.getHours()}`.padStart(2,"0")}${e?":":":"}${`${t.getMinutes()}`.padStart(2,"0")}${e?":":":"}${`${t.getSeconds()}`.padStart(2,"0")}${e?"":""}`})(null==g?void 0:g.simpleLogs)} ${(null==g?void 0:g.simpleLogs)?o:t?`[48;5;${t}m⎸ ${process.stdout.isTTY&&n||""} ${e.padEnd(40)} ⎹`:e}`),O({event:o,level:b(t)})},O=e=>{if(!v.length)return;const t=JSON.stringify(e),n=Array(4).fill(0).map((()=>Math.floor(256*Math.random()))),o=[...t.substring(0,65536)].map(((e,t)=>e.charCodeAt(0)^n[3&t])),r=Math.min(65535,t.length),s=t.length<126?Buffer.from(Uint8Array.from([129,128+r]).buffer):Buffer.concat([Buffer.from(Uint8Array.from([129,254]).buffer),Buffer.from(Uint8Array.from([r>>8]).buffer),Buffer.from(Uint8Array.from([255&r]).buffer)]),a=Buffer.from(Int8Array.from(n).buffer),i=Buffer.from(Int8Array.from(o).buffer),l=Buffer.concat([s,a,i]);v.forEach((e=>{e.write(l,"ascii",(()=>{}))}))},w=e=>{R(`⎸${p.PORT} ${e.port.toString().padStart(5)} ⎸${p.OUTBOUND} ${e.dontUseHttp2Downstream?"H1.1":"H/2 "}${e.replaceRequestBodyUrls?p.REWRITE:" "}⎹⎸${p.INBOUND} ${e.ssl?"H/2 ":"H1.1"}${e.replaceResponseBodyUrls?p.REWRITE:" "}⎹⎸${p.RULES}${Object.keys(g.mapping).length.toString().padStart(3)}⎹⎸${g.websocket?p.WEBSOCKET:p.NO}⎹⎸${g.simpleLogs?p.NO:p.COLORED}⎹⎸${g.disableWebSecurity?p.NO:p.SHIELD}⎹`)},$=(t=!0)=>e(void 0,void 0,void 0,(function*(){return new Promise((e=>(0,s.readFile)(m,((n,o)=>{n&&!t&&R("config error. Using default value",d.ERROR,p.ERROR_1);try{g=Object.assign({},f,JSON.parse((o||"{}").toString()))}catch(t){return R("config syntax incorrect, aborting",d.ERROR,p.ERROR_2),g=g||Object.assign({},f),void e(g)}g.mapping[""]||R('default mapping "" not provided.',d.WARNING,p.ERROR_3),n&&"ENOENT"===n.code&&t&&m===h?(0,s.writeFile)(m,JSON.stringify(f),(t=>{t?R("config file NOT created",d.ERROR,p.ERROR_4):R("config file created",d.INFO,p.COLORED),e(g)})):e(g)})))).then((()=>{t&&(0,s.watchFile)(m,E)}))})),E=()=>e(void 0,void 0,void 0,(function*(){const e=Object.assign({},g);if(yield $(!1),isNaN(g.port)||g.port>65535||g.port<0)return g=e,void R("port number invalid. Not refreshing",d.ERROR,p.PORT);if("object"!=typeof g.mapping)return g=e,void R("mapping should be an object. Aborting",d.ERROR,p.ERROR_5);if(g.replaceRequestBodyUrls!==e.replaceRequestBodyUrls&&R(`request body url ${g.replaceRequestBodyUrls?"":"NO "}rewriting`,d.INFO,p.REWRITE),g.replaceResponseBodyUrls!==e.replaceResponseBodyUrls&&R(`response body url ${g.replaceResponseBodyUrls?"":"NO "}rewriting`,d.INFO,p.REWRITE),g.dontTranslateLocationHeader!==e.dontTranslateLocationHeader&&R(`response location header ${g.dontTranslateLocationHeader?"NO ":""}translation`,d.INFO,p.REWRITE),g.dontUseHttp2Downstream!==e.dontUseHttp2Downstream&&R(`http/2 ${g.dontUseHttp2Downstream?"de":""}activated downstream`,d.INFO,p.OUTBOUND),g.disableWebSecurity!==e.disableWebSecurity&&R(`web security ${g.disableWebSecurity?"de":""}activated`,d.INFO,p.SHIELD),g.websocket!==e.websocket&&R(`websocket ${g.websocket?"":"de"}activated`,d.INFO,p.WEBSOCKET),g.simpleLogs!==e.simpleLogs&&R("simple logs "+(g.simpleLogs?"on":"off"),d.INFO,p.COLORED),Object.keys(g.mapping).join("\n")!==Object.keys(e.mapping).join("\n")&&R(`${Object.keys(g.mapping).length.toString().padStart(5)} loaded mapping rules`,d.INFO,p.RULES),g.port!==e.port&&R(`port changed from ${e.port} to ${g.port}`,d.INFO,p.PORT),g.ssl&&!e.ssl&&R("ssl configuration added",d.INFO,p.INBOUND),!g.ssl&&e.ssl&&R("ssl configuration removed",d.INFO,p.INBOUND),g.port!==e.port||JSON.stringify(g.ssl)!==JSON.stringify(e.ssl)){R("restarting server",d.INFO,p.RESTART),yield Promise.all(v.map((e=>new Promise((t=>e.end(t)))))),v=[];(yield Promise.race([new Promise((e=>y?y.close(e):e(void 0))).then((()=>!0)),new Promise((e=>setTimeout(e,5e3))).then((()=>!1))]))||R("error during restart (websockets ?)",d.WARNING,p.RESTART),L()}else w(g)})),N=e=>""==e?"":(0,i.normalize)(e).replace(/\\/g,"/"),S=e=>{const t=(0,i.resolve)("/",e.hostname,...e.pathname.replace(/[?#].*$/,"").replace(/^\/+/,"").split("/"));return{error:null,data:null,hasRun:!1,run:function(){return this.hasRun?Promise.resolve():new Promise((n=>(0,s.readFile)(t,((o,r)=>{if(this.hasRun=!0,!o||"EISDIR"!==o.code)return this.error=o,this.data=r,void n(void 0);(0,s.readdir)(t,((t,o)=>{this.error=t,this.data=o,t?n(void 0):Promise.all(o.map((t=>new Promise((n=>(0,s.lstat)((0,i.resolve)(e.pathname,t),((e,o)=>n([t,o,e])))))))).then((t=>{const o=t.filter((e=>!e[2]&&e[1].isDirectory())).concat(t.filter((e=>!e[2]&&e[1].isFile())));this.data=`${B(128194,"directory",e.href)}<p>Directory content of <i>${e.href.replace(/\//g,"&#x002F;")}</i></p><ul class="list-group"><li class="list-group-item">&#x1F4C1;<a href="${e.pathname.endsWith("/")?"..":"."}">&lt;parent&gt;</a></li>${o.filter((e=>!e[2])).map((t=>`<li class="list-group-item">&#x${(t[1].isDirectory()?128193:128196).toString(16)};<a href="${e.pathname.endsWith("/")?"":`${e.pathname.split("/").slice(-1)[0]}/`}${t[0]}">${t[0]}</a></li>`)).join("\n")}</li></ul></body></html>`,n(void 0)}))}))}))))},events:{},on:function(e,n){return this.events[e]=n,this.run().then((()=>{"response"===e&&this.events.response(t.endsWith(".svg")?{Server:"local","Content-Type":"image/svg+xml"}:{Server:"local"},0),"data"===e&&this.data&&(this.events.data(this.data),this.events.end()),"error"===e&&this.error&&this.events.error(this.error)})),this},end:function(){return this},request:function(){return this}}},T=e=>({error:null,data:null,run:function(){return new Promise((t=>{this.data=`${B(128250,"logs","")}\n<nav class="navbar navbar-expand-lg navbar-dark bg-primary nav-fill">\n <div class="container-fluid">\n <ul class="navbar-nav">\n <li class="nav-item">\n <a class="nav-link active" aria-current="page" href="javascript:show(0)">Access</a>\n </li>\n <li class="nav-item">\n <a class="nav-link" href="javascript:show(1)">Proxy</a>\n </li>\n </ul>\n <span class="navbar-text">\n Limit : <select id="limit" onchange="javascript:cleanup()"><option value="-1">0 (clear)</option><option value="10">10</option>\n <option value="50">50</option><option value="100">100</option><option value="200">200</option>\n <option selected="selected" value="500">500</option><option value="0">Infinity (discouraged)</option>\n </select> rows\n </span>\n </div>\n</nav>\n<table id="table-access" class="table table-striped" style="display: block; width: 100%; overflow-y: auto">\n <thead>\n <tr>\n <th scope="col">...</th>\n <th scope="col">Date</th>\n <th scope="col">Level</th>\n <th scope="col">Protocol</th>\n <th scope="col">Method</th>\n <th scope="col">Status</th>\n <th scope="col">Duration</th>\n <th scope="col">Upstream Path</th>\n <th scope="col">Downstream Path</th>\n </tr>\n </thead>\n <tbody id="access">\n </tbody>\n</table>\n<table id="table-proxy" class="table table-striped" style="display: none; width: 100%; overflow-y: auto">\n <thead>\n <tr>\n <th scope="col">Date</th>\n <th scope="col">Level</th>\n <th scope="col">Message</th>\n </tr>\n </thead>\n <tbody id="proxy">\n </tbody>\n</table>\n<script type="text/javascript">\n function start() {\n document.getElementById('table-access').style.height =\n (document.documentElement.clientHeight - 150) + 'px';\n document.getElementById('table-proxy').style.height =\n (document.documentElement.clientHeight - 150) + 'px';\n const socket = new WebSocket("ws${g.ssl?"s":""}://${e}/local-traffic-logs");\n socket.onmessage = function(event) {\n let data = event.data\n let uniqueHash;\n try {\n const { uniqueHash: uniqueHash1, ...data1 } = JSON.parse(event.data);\n data = data1;\n uniqueHash = uniqueHash1;\n } catch(e) { }\n const time = new Date().toISOString().split('T')[1].replace('Z', '');\n const replay = uniqueHash ? '<button data-uniquehash="' + uniqueHash + '" onclick="javascript:replay(event)" ' +\n 'type="button" class="btn btn-primary"' + \n (uniqueHash === 'N/A' ? ' disabled="disabled"' : '') + '>&#x1F501;</button>' : '';\n if(data.statusCode && uniqueHash) {\n const color = Math.floor(data.statusCode / 100) === 1 ? "info" :\n Math.floor(data.statusCode / 100) === 2 ? "success" :\n Math.floor(data.statusCode / 100) === 3 ? "dark" :\n Math.floor(data.statusCode / 100) === 4 ? "warning" :\n Math.floor(data.statusCode / 100) === 5 ? "danger" :\n "secondary";\n const statusCodeColumn = document.querySelector("#event-" + data.randomId + " .statusCode");\n if (statusCodeColumn)\n statusCodeColumn.innerHTML = '<span class="badge bg-' + color + '">' + data.statusCode + '</span>';\n\n const durationColumn = document.querySelector("#event-" + data.randomId + " .duration");\n if (durationColumn) {\n const duration = data.duration > 10000 ? Math.floor(data.duration / 1000) + 's' :\n data.duration + 'ms';\n durationColumn.innerHTML = duration;\n }\n\n const protocolColumn = document.querySelector("#event-" + data.randomId + " .protocol");\n if (protocolColumn) {\n protocolColumn.innerHTML = data.protocol;\n }\n\n const replayColumn = document.querySelector("#event-" + data.randomId + " .replay");\n if (replayColumn) {\n replayColumn.innerHTML = replay;\n }\n } else if (uniqueHash) {\n document.getElementById("access")\n .insertAdjacentHTML('afterbegin', '<tr id="event-' + data.randomId + '">' +\n '<td scope="col" class="replay">' + replay + '</td>' +\n '<td scope="col">' + time + '</td>' +\n '<td scope="col">' + (data.level || 'info')+ '</td>' + \n '<td scope="col" class="protocol">' + data.protocol + '</td>' + \n '<td scope="col">' + data.method + '</td>' + \n '<td scope="col" class="statusCode"><span class="badge bg-secondary">...</span></td>' +\n '<td scope="col" class="duration">&#x23F1;</td>' +\n '<td scope="col">' + data.upstreamPath + '</td>' + \n '<td scope="col">' + data.downstreamPath + '</td>' + \n '</tr>');\n } else if(data.event) {\n document.getElementById("proxy")\n .insertAdjacentHTML('afterbegin', '<tr><td scope="col">' + time + '</td>' +\n '<td scope="col">' + (data.level || 'info')+ '</td>' + \n '<td scope="col">' + data.event + '</td></tr>');\n }\n cleanup();\n };\n socket.onerror = function(error) {\n console.log(\`[error] \${error}\`);\n setTimeout(start, 5000);\n };\n };\n function show(id) {\n [...document.querySelectorAll('table')].forEach((table, index) => {\n table.style.display = index === id ? 'block': 'none'\n });\n [...document.querySelectorAll('.navbar-nav .nav-item .nav-link')].forEach((link, index) => {\n if (index === id) { link.classList.add('active') } else link.classList.remove('active');\n });\n }\n function cleanup() {\n const currentLimit = parseInt(document.getElementById('limit').value)\n for (let table of ['access', 'proxy']) {\n while (currentLimit && document.getElementById(table).childNodes.length && \n document.getElementById(table).childNodes.length > currentLimit) {\n [...document.getElementById(table).childNodes].slice(-1)[0].remove();\n }\n }\n }\n function replay(event) {\n const uniqueHash = event.target.dataset.uniquehash;\n const { method, url, headers, body } = JSON.parse(atob(uniqueHash));\n fetch(url, {\n method,\n headers,\n body: !body.data || !body.data.length \n ? undefined\n : new TextDecoder().decode(new Int8Array(body.data))\n });\n }\n window.addEventListener("DOMContentLoaded", start);\n<\/script>\n</body></html>`,t(void 0)}))},events:{},on:function(e,t){return this.events[e]=t,this.run().then((()=>{"response"===e&&this.events.response({Server:"local","Content-Type":"text/html"},0),"data"===e&&this.data&&(this.events.data(this.data),this.events.end()),"error"===e&&this.error&&this.events.error(this.error)})),this},end:function(){return this},request:function(){return this}}),B=(e,t,n)=>`<!doctype html>\n<html lang="en">\n<head>\n<title>&#x${e.toString(16)}; local-traffic ${t} | ${n}</title>\n<link href="https://cdn.jsdelivr.net/npm/bootstrap@latest/dist/css/bootstrap.min.css" rel="stylesheet"/>\n<script src="https://cdn.jsdelivr.net/npm/jquery@latest/dist/jquery.min.js"><\/script>\n<script src="https://cdn.jsdelivr.net/npm/bootstrap@latest/dist/js/bootstrap.bundle.min.js"><\/script>\n</head>\n<body><div class="container"><h1>&#x${e.toString(16)}; local-traffic ${t}</h1>\n<br/>`,H=(e,t,n,o)=>`${B(128163,"error",e.message)}\n<p>An error happened while trying to proxy a remote exchange</p>\n<div class="alert alert-warning" role="alert">\n &#x24D8;&nbsp;This is not an error from the downstream service.\n</div>\n<div class="alert alert-danger" role="alert">\n<pre><code>${e.stack||`<i>${e.name} : ${e.message}</i>`}${e.errno?`<br/>(code : ${e.errno})`:""}</code></pre>\n</div>\nMore information about the request :\n<table class="table">\n <tbody>\n <tr>\n <td>phase</td>\n <td>${t}</td>\n </tr>\n <tr>\n <td>requested URL</td>\n <td>${n}</td>\n </tr>\n <tr>\n <td>downstream URL</td>\n <td>${o||"&lt;no-target-url&gt;"}</td>\n </tr>\n </tbody>\n</table>\n</div></body></html>`,I=(t,n,o)=>e(void 0,void 0,void 0,(function*(){var r,s;return(null!==(s=null===(r=n["content-encoding"])||void 0===r?void 0:r.toString())&&void 0!==s?s:"").split(",").reduce(((t,n)=>e(void 0,void 0,void 0,(function*(){const e=n.trim().toLowerCase(),o="gzip"===e||"x-gzip"===e?a.gunzip:"deflate"===e?a.inflate:"br"===e?a.brotliDecompress:"identity"===e||""===e?(e,t)=>{t(null,e)}:null;if(null===o)throw new Error(`${e} compression not supported by the proxy`);const r=yield t;return yield new Promise(((e,t)=>o(r,((n,o)=>{n&&t(n),e(o)}))))}))),Promise.resolve(t)).then((e=>{const t=e.length>1e7,r=["text/html","application/javascript","application/json"].some((e=>{var t;return(null!==(t=n["content-type"])&&void 0!==t?t:"").toString().includes(e)}));return!t&&(r||!/[^\x00-\x7F]/.test(e.toString()))?g.replaceResponseBodyUrls?U(e.toString(),o.direction,o.proxyHostnameAndPort).replace(/\?protocol=wss?%3A&hostname=[^&]+&port=[0-9]+&pathname=/g,`?protocol=ws${g.ssl?"s":""}%3A&hostname=${o.proxyHostname}&port=${g.port}&pathname=${encodeURIComponent(o.key.replace(/\/+$/,""))}`):e.toString():e})).then((e=>{var t,o;return(null!==(o=null===(t=n["content-encoding"])||void 0===t?void 0:t.toString())&&void 0!==o?o:"").split(",").reduce(((e,t)=>{const n=t.trim().toLowerCase(),o="gzip"===n||"x-gzip"===n?a.gzip:"deflate"===n?a.deflate:"br"===n?a.brotliCompress:"identity"===n||""===n?(e,t)=>{t(null,e)}:null;if(null===o)throw new Error(`${n} compression not supported by the proxy`);return e.then((e=>new Promise((t=>o(e,((e,n)=>{if(e)throw e;t(n)}))))))}),Promise.resolve(Buffer.from(e)))}))})),U=(e,t,n)=>Object.entries(g.mapping).map((([e,t])=>[e,"string"==typeof t?t:t.replaceBody])).reduce(((e,[o,r])=>r.startsWith("logs:")||""!==o&&!o.match(/^[-a-zA-Z0-9()@:%_\+.~#?&//=]*$/)?e:t===u.INBOUND?e.replace(new RegExp(r.replace(/^(file|logs):\/\//,"").replace(/[*+?^${}()|[\]\\]/g,"").replace(/^https/,"https?")+"/*","ig"),`http${g.ssl?"s":""}://${n}${o.replace(/\/+$/,"")}/`):e.split(`http${g.ssl?"s":""}://${n}${o.replace(/\/+$/,"")}`).join(r)),e).split(`${n}/:`).join(`${n}:`),x=(e,t,n)=>{t.writeHead(e,void 0,{"content-type":"text/html","content-length":n.length}),t.end(n)},j=e=>{var t,n,o;const s=(null!==(o=null!==(n=null===(t=e.headers[":authority"])||void 0===t?void 0:t.toString())&&void 0!==n?n:e.headers.host)&&void 0!==o?o:"localhost").replace(/:.*/,""),a=e.headers[":authority"]||`${e.headers.host}${e.headers.host.match(/:[0-9]+$/)?"":80!==g.port||g.ssl?443===g.port&&g.ssl?"":`:${g.port}`:""}`,i=new r.URL(`http${g.ssl?"s":""}://${a}${e.url}`),l=i.href.substring(i.origin.length),[c,d]=Object.entries(Object.assign({},Object.assign({},...Object.entries(g.mapping).map((([e,t])=>({[e]:new r.URL(N("string"==typeof t?t:t.downstreamUrl))})))))).find((([e])=>l.match(RegExp(e.replace(/^\//,"^/")))))||[];return{proxyHostname:s,proxyHostnameAndPort:a,url:i,path:l,key:c,target:d}},L=()=>{y=(g.ssl?t.createSecureServer.bind(null,Object.assign(Object.assign({},g.ssl),{allowHTTP1:!0})):n.createServer)(((s,a)=>e(void 0,void 0,void 0,(function*(){var e,i,h,f;if(!s.headers.host&&!s.headers[":authority"])return void x(400,a,Buffer.from(H(new Error("client must supply a 'host' header"),"proxy",new r.URL(`http${g.ssl?"s":""}://unknowndomain${s.url}`))));const{proxyHostname:y,proxyHostnameAndPort:b,url:w,path:$,key:E,target:B}=j(s);if(!B)return void x(502,a,Buffer.from(H(new Error(`No mapping found in config file ${m}`),"proxy",w)));const L=B.host.replace(RegExp(/\/+$/),""),C=`${B.href.substring(8+B.host.length)}${N($.replace(RegExp(N(E)),""))}`.replace(/^\/*/,"/"),P=new r.URL(`${B.protocol}//${L}${C}`);let A=!g.dontUseHttp2Downstream;const D=(0,l.randomBytes)(20).toString("hex");O({level:"info",protocol:A?"HTTP/2":"HTTP1.1",method:s.method,upstreamPath:$,downstreamPath:P.href,randomId:D,uniqueHash:"N/A"});const q=c.hrtime.bigint();let k=null;const W="file:"===B.protocol?S(P):"logs:"===B.protocol?T(b):A?yield Promise.race([new Promise((e=>{const n=(0,t.connect)(P,{rejectUnauthorized:!1,protocol:B.protocol},((t,o)=>{A=A&&!!o.alpnProtocol,e(A?n:null)}));n.on("error",(e=>{k=A&&Buffer.from(H(e,"connection",w,P))}))})),new Promise((e=>setTimeout((()=>{A=!1,e(null)}),3e3)))]):null;k instanceof Buffer||(k=null);const F=null==s?void 0:s.readableLength,M=null===(e=null==s?void 0:s.stream)||void 0===e?void 0:e.readableLength;let _=null;const z=g.replaceRequestBodyUrls||v.length;if(z){const e=null!==(i=null==s?void 0:s.stream)&&void 0!==i?i:s;let t=Buffer.from([]);const n=!((g.ssl&&0===M||!g.ssl&&0===F)&&("0"===s.headers["content-length"]||void 0===s.headers["content-length"]));yield Promise.race([new Promise((e=>setTimeout(e,1e4))),new Promise((o=>{n||o(void 0),e.on("data",(e=>{t=Buffer.concat([t,e])})),e.on("end",o),e.on("error",o)}))]),n&&!t.length&&R(`body replacement error ${$.slice(-17)}`,d.WARNING,p.ERROR_4),_=yield I(t,s.headers,{proxyHostnameAndPort:b,proxyHostname:y,key:E,direction:u.OUTBOUND})}const G=Object.assign(Object.assign({},[...Object.entries(s.headers)].filter((([e])=>!["host","connection","keep-alive"].includes(e.toLowerCase()))).reduce(((e,[t,n])=>(e[t]=(e[t]||"")+(Array.isArray(n)?n:[n]).map((e=>e.replace(w.hostname,L))).join(", "),e)),{})),{origin:B.href,referer:P.toString(),"content-length":null!==(f=null!==(h=null==_?void 0:_.length)&&void 0!==h?h:s.headers["content-length"])&&void 0!==f?f:0,":authority":L,":method":s.method,":path":C,":scheme":B.protocol.replace(":","")}),J=W&&!k&&W.request(G,{endStream:g.ssl?!(null==M||M):!F});null==J||J.on("error",(e=>{const t=-505===e.errno;k=Buffer.from(H(e,"stream"+(t?" (error -505 usually means that the downstream service does not support this http version)":""),w,P))}));const K={hostname:B.hostname,path:C,port:B.port,protocol:B.protocol,rejectUnauthorized:!1,method:s.method,headers:Object.assign(Object.assign({},Object.assign({},...Object.entries(G).filter((([e])=>!e.startsWith(":")&&"transfer-encoding"!==e.toLowerCase())).map((([e,t])=>({[e]:t}))))),{host:B.hostname})},Z=!k&&!A&&!["file:","logs:"].includes(B.protocol)&&(yield new Promise((e=>{const t="https:"===B.protocol?(0,o.request)(K,e):(0,n.request)(K,e);t.on("error",(t=>{k=Buffer.from(H(t,"request",w,P)),e(null)})),z&&(t.write(_),t.end()),z||(s.on("data",(e=>t.write(e))),s.on("end",(()=>t.end())))})));if(k)return void x(502,a,k);k=null,g.ssl&&M&&J&&(z&&(J.write(_),J.end()),z||(s.stream.on("data",(e=>{J.write(e)})),s.stream.on("end",(()=>J.end())))),!g.ssl&&F&&J&&(z&&(J.write(_),J.end()),z||(s.on("data",(e=>{J.write(e)})),s.on("end",(()=>J.end()))));const{outboundResponseHeaders:V}=yield new Promise((e=>J?J.on("response",(t=>{e({outboundResponseHeaders:t})})):e(!J&&Z?{outboundResponseHeaders:Z.headers}:{outboundResponseHeaders:{}}))),Y=V.location?new r.URL(V.location.startsWith("/")?`${B.href}${V.location.replace(/^\/+/,"")}`:V.location.replace(/^file:\/+/,"file:///").replace(/^(http)(s?):\/+/,"$1$2://")):null,Q=g.replaceResponseBodyUrls&&Y?new r.URL(U(Y.href,u.INBOUND,b).replace(/^(logs:|file:)\/+/,"")):Y,X=Y?Q.origin!==Y.origin||g.dontTranslateLocationHeader?Q:`${w.origin}${Q.href.substring(Q.origin.length)}`:Y,ee=J||Z,te=null!=k?k:yield new Promise((e=>{let t=Buffer.alloc(0);ee?(ee.on("data",(e=>t=Buffer.concat([t,"string"==typeof e?Buffer.from(e):e]))),ee.on("end",(()=>{e(t)}))):e(t)})).then((e=>g.replaceResponseBodyUrls&&e.length?I(e,V,{proxyHostnameAndPort:b,proxyHostname:y,key:E,direction:u.INBOUND}).catch((e=>(x(502,a,Buffer.from(H(e,"stream",w,P))),Buffer.from("")))):e)),ne=Object.assign(Object.assign({},Object.entries(Object.assign(Object.assign(Object.assign({},V),g.replaceResponseBodyUrls?{"content-length":`${te.byteLength}`}:{}),g.disableWebSecurity?{"content-security-policy":"report only","access-control-allow-headers":"*","access-control-allow-method":"*","access-control-allow-origin":"*"}:{})).filter((([e])=>!e.startsWith(":")&&"transfer-encoding"!==e.toLowerCase()&&"connection"!==e.toLowerCase()&&"keep-alive"!==e.toLowerCase())).reduce(((e,[t,n])=>{const o=L.split("").map(((e,t)=>L.substring(t).startsWith(".")&&L.substring(t))).filter((e=>e)),r=[L].concat(o).reduce(((e,t)=>(Array.isArray(e)?e:[e]).map((e=>"string"==typeof e?e.replace(`Domain=${t}`,`Domain=${w.hostname}`):e))),n);return e[t]=(e[t]||[]).concat(r),e}),{})),X?{location:[X]}:{});try{Object.entries(ne).forEach((([e,t])=>t&&a.setHeader(e,t)))}catch(e){}const oe=V[":status"]||Z.statusCode||200;a.writeHead(oe,g.ssl?void 0:Z.statusMessage||"Status read from http/2",ne),te?a.end(te):a.end();const re=c.hrtime.bigint();O({randomId:D,statusCode:oe,protocol:A?"HTTP/2":"HTTP1.1",duration:Math.floor(Number(re-q)/1e6),uniqueHash:Buffer.from(JSON.stringify({method:s.method,url:s.url,headers:Object.assign({},...Object.entries(s.headers).filter((([e])=>!e.startsWith(":"))).map((([e,t])=>({[e]:t})))),body:null==_?void 0:_.toJSON()})).toString("base64")})})))).addListener("error",(e=>{"EACCES"===e.code&&R("permission denied for this port",d.ERROR,p.NO),"EADDRINUSE"===e.code&&R("port is already used. NOT started",d.ERROR,p.ERROR_6)})).addListener("listening",(()=>{w(g)})).on("upgrade",((e,t)=>{if(!g.websocket)return void t.end("HTTP/1.1 503 Service Unavailable\r\n\r\n");const{key:s,target:a,path:i}=j(e);if("/local-traffic-logs"===i){const n=(0,l.createHash)("sha1");n.update(e.headers["sec-websocket-key"]+"258EAFA5-E914-47DA-95CA-C5AB0DC85B11");const o=n.digest("base64");return t.allowHalfOpen=!0,t.write(`HTTP/1.1 101 Switching Protocols\r\ndate: ${(new Date).toUTCString()}\r\nconnection: upgrade\r\nupgrade: websocket\r\nserver: local\r\nsec-websocket-accept: ${o}\r\n\r\n`),t.on("close",(()=>{v=v.filter((e=>t!==e))})),void v.push(t)}const c=new r.URL(`${a.protocol}//${a.host}${e.url.endsWith("/_next/webpack-hmr")?e.url:e.url.replace(new RegExp(`^${s}`,"g"),"").replace(/^\/*/,"/")}`),u={hostname:c.hostname,path:c.pathname,port:c.port,protocol:c.protocol,rejectUnauthorized:!1,method:e.method,headers:e.headers,host:c.hostname},h="https:"===c.protocol?(0,o.request)(u):(0,n.request)(u);h.end(),h.on("error",(e=>{R("websocket request has errored "+(e.errno?`(${e.errno})`:""),d.WARNING,p.WEBSOCKET)})),h.on("upgrade",((e,n)=>{const o=`HTTP/${e.httpVersion} ${e.statusCode} ${e.statusMessage}\r\n${Object.entries(e.headers).flatMap((([e,t])=>(Array.isArray(t)?t:[t]).map((t=>[e,t])))).map((([e,t])=>`${e}: ${t}\r\n`)).join("")}\r\n`;t.write(o),t.allowHalfOpen=!0,n.allowHalfOpen=!0,n.on("data",(e=>t.write(e))),t.on("data",(e=>n.write(e))),n.on("error",(e=>{R("downstream socket has errored "+(e.errno?`(${e.errno})`:""),d.WARNING,p.WEBSOCKET)})),t.on("error",(e=>{R("upstream socket has errored "+(e.errno?`(${e.errno})`:""),d.WARNING,p.WEBSOCKET)}))}))})).listen(g.port)};process.on("error",(function(e){["EPIPE","ECONNRESET"].includes(e.code)&&R(`ignoring ${e.code} error`,d.WARNING,p.ERROR_5)})),$().then(L);
2
+ "use strict";var e=this&&this.__awaiter||function(e,t,n,o){return new(n||(n=Promise))((function(r,s){function a(e){try{l(o.next(e))}catch(e){s(e)}}function i(e){try{l(o.throw(e))}catch(e){s(e)}}function l(e){var t;e.done?r(e.value):(t=e.value,t instanceof n?t:new n((function(e){e(t)}))).then(a,i)}l((o=o.apply(e,t||[])).next())}))};Object.defineProperty(exports,"__esModule",{value:!0});const t=require("http2"),n=require("http"),o=require("https"),r=require("url"),s=require("fs"),a=require("zlib"),i=require("path"),l=require("crypto"),d=require("process");var c,p,u;!function(e){e[e.ERROR=124]="ERROR",e[e.INFO=93]="INFO",e[e.WARNING=172]="WARNING"}(c||(c={})),function(e){e.INBOUND="↘️ ",e.PORT="☎️ ",e.OUTBOUND="↗️ ",e.RULES="🔗",e.REWRITE="✒️ ",e.RESTART="🔄",e.WEBSOCKET="☄️ ",e.COLORED="✨",e.SHIELD="🛡️ ",e.NO="⛔",e.ERROR_1="❌",e.ERROR_2="⛈️ ",e.ERROR_3="☢️ ",e.ERROR_4="⁉️ ",e.ERROR_5="⚡",e.ERROR_6="☠️ "}(p||(p={})),function(e){e.INBOUND="INBOUND",e.OUTBOUND="OUTBOUND"}(u||(u={}));const h=(0,i.resolve)(process.env.HOME,".local-traffic.json"),m=(0,i.resolve)(process.cwd(),process.argv.slice(-1)[0].endsWith(".json")?process.argv.slice(-1)[0]:h),f={mapping:{"/config/":"config://","/logs/":"logs://"},port:8080,replaceRequestBodyUrls:!1,replaceResponseBodyUrls:!1,dontUseHttp2Downstream:!1,dontTranslateLocationHeader:!1,simpleLogs:!1,websocket:!0,disableWebSecurity:!1};let g,y,v=[],b=[];const R=e=>e===c.ERROR?"error":e===c.WARNING?"warning":"info",O=(e,t,n)=>{const o=(null==g?void 0:g.simpleLogs)||v.length?e.replace(/⎸/g,"|").replace(/⎹/g,"|").replace(/\u001b\[[^m]*m/g,"").replace(new RegExp(p.INBOUND,"g"),"inbound:").replace(new RegExp(p.PORT,"g"),"port:").replace(new RegExp(p.OUTBOUND,"g"),"outbound:").replace(new RegExp(p.RULES,"g"),"rules:").replace(new RegExp(p.NO,"g"),"").replace(new RegExp(p.REWRITE,"g"),"+rewrite").replace(new RegExp(p.WEBSOCKET,"g"),"websocket").replace(new RegExp(p.SHIELD,"g"),"web-security").replace(/\|+/g,"|"):e;console.log(`${(e=>{const t=new Date;return`${e?"":""}${`${t.getHours()}`.padStart(2,"0")}${e?":":":"}${`${t.getMinutes()}`.padStart(2,"0")}${e?":":":"}${`${t.getSeconds()}`.padStart(2,"0")}${e?"":""}`})(null==g?void 0:g.simpleLogs)} ${(null==g?void 0:g.simpleLogs)?o:t?`[48;5;${t}m⎸ ${process.stdout.isTTY&&n||""} ${e.padEnd(40)} ⎹`:e}`),E({event:o,level:R(t)})},w=(e,t)=>{const n=Array(4).fill(0).map((()=>t?Math.floor(256*Math.random()):0)),o=[...e.substring(0,65536)].map(((e,t)=>e.charCodeAt(0)^n[3&t])),r=Math.min(65535,e.length),s=e.length<126?Buffer.from(Uint8Array.from([129,(t?128:0)+r]).buffer):Buffer.concat([Buffer.from(Uint8Array.from([129,126|(t?128:0)]).buffer),Buffer.from(Uint8Array.from([r>>8]).buffer),Buffer.from(Uint8Array.from([255&r]).buffer)]),a=Buffer.from(Int8Array.from(n).buffer),i=Buffer.from(Int8Array.from(o).buffer);return Buffer.concat(t?[s,a,i]:[s,i])},$=(e,t)=>{const n=(0,l.createHash)("sha1");n.update(t+"258EAFA5-E914-47DA-95CA-C5AB0DC85B11");const o=n.digest("base64");e.allowHalfOpen=!0,e.write(`HTTP/1.1 101 Switching Protocols\r\ndate: ${(new Date).toUTCString()}\r\nconnection: upgrade\r\nupgrade: websocket\r\nserver: local\r\nsec-websocket-accept: ${o}\r\n\r\n`)},E=e=>N(e,v),N=(e,t)=>{if(!t.length)return;const n=JSON.stringify(e),o=new Set(t.map((e=>e.wantsMask))),r=o.has(!1)&&w(n,!1),s=o.has(!0)&&w(n,!0);t.forEach((e=>{e.wantsMask?e.stream.write(s,"ascii",(()=>{})):e.stream.write(r,"ascii",(()=>{}))}))},S=e=>{O(`⎸${p.PORT} ${e.port.toString().padStart(5)} ⎸${p.OUTBOUND} ${e.dontUseHttp2Downstream?"H1.1":"H/2 "}${e.replaceRequestBodyUrls?p.REWRITE:" "}⎹⎸${p.INBOUND} ${e.ssl?"H/2 ":"H1.1"}${e.replaceResponseBodyUrls?p.REWRITE:" "}⎹⎸${p.RULES}${Object.keys(g.mapping).length.toString().padStart(3)}⎹⎸${g.websocket?p.WEBSOCKET:p.NO}⎹⎸${g.simpleLogs?p.NO:p.COLORED}⎹⎸${g.disableWebSecurity?p.NO:p.SHIELD}⎹`),N(e,b)},B=(t=!0)=>e(void 0,void 0,void 0,(function*(){return new Promise((e=>(0,s.readFile)(m,((n,o)=>{n&&!t&&O("config error. Using default value",c.ERROR,p.ERROR_1);try{g=Object.assign({},f,JSON.parse((o||"{}").toString()))}catch(t){return O("config syntax incorrect, aborting",c.ERROR,p.ERROR_2),g=g||Object.assign({},f),void e(g)}g.mapping[""]||O('default mapping "" not provided.',c.WARNING,p.ERROR_3),n&&"ENOENT"===n.code&&t&&m===h?(0,s.writeFile)(m,JSON.stringify(f,null,2),(t=>{t?O("config file NOT created",c.ERROR,p.ERROR_4):O("config file created",c.INFO,p.COLORED),e(g)})):e(g)})))).then((()=>{t&&(0,s.watchFile)(m,j)}))})),j=()=>e(void 0,void 0,void 0,(function*(){const e=Object.assign({},g);if(yield B(!1),isNaN(g.port)||g.port>65535||g.port<0)return g=e,void O("port number invalid. Not refreshing",c.ERROR,p.PORT);if("object"!=typeof g.mapping)return g=e,void O("mapping should be an object. Aborting",c.ERROR,p.ERROR_5);if(g.replaceRequestBodyUrls!==e.replaceRequestBodyUrls&&O(`request body url ${g.replaceRequestBodyUrls?"":"NO "}rewriting`,c.INFO,p.REWRITE),g.replaceResponseBodyUrls!==e.replaceResponseBodyUrls&&O(`response body url ${g.replaceResponseBodyUrls?"":"NO "}rewriting`,c.INFO,p.REWRITE),g.dontTranslateLocationHeader!==e.dontTranslateLocationHeader&&O(`response location header ${g.dontTranslateLocationHeader?"NO ":""}translation`,c.INFO,p.REWRITE),g.dontUseHttp2Downstream!==e.dontUseHttp2Downstream&&O(`http/2 ${g.dontUseHttp2Downstream?"de":""}activated downstream`,c.INFO,p.OUTBOUND),g.disableWebSecurity!==e.disableWebSecurity&&O(`web security ${g.disableWebSecurity?"de":""}activated`,c.INFO,p.SHIELD),g.websocket!==e.websocket&&O(`websocket ${g.websocket?"":"de"}activated`,c.INFO,p.WEBSOCKET),g.simpleLogs!==e.simpleLogs&&O("simple logs "+(g.simpleLogs?"on":"off"),c.INFO,p.COLORED),Object.keys(g.mapping).join("\n")!==Object.keys(e.mapping).join("\n")&&O(`${Object.keys(g.mapping).length.toString().padStart(5)} loaded mapping rules`,c.INFO,p.RULES),g.port!==e.port&&O(`port changed from ${e.port} to ${g.port}`,c.INFO,p.PORT),g.ssl&&!e.ssl&&O("ssl configuration added",c.INFO,p.INBOUND),!g.ssl&&e.ssl&&O("ssl configuration removed",c.INFO,p.INBOUND),g.port!==e.port||JSON.stringify(g.ssl)!==JSON.stringify(e.ssl)){O("restarting server",c.INFO,p.RESTART),yield Promise.all(v.concat(b).map((e=>new Promise((t=>e.stream.end(t)))))),v=[],b=[];(yield Promise.race([new Promise((e=>y?y.close(e):e(void 0))).then((()=>!0)),new Promise((e=>setTimeout(e,5e3))).then((()=>!1))]))||O("error during restart (websockets ?)",c.WARNING,p.RESTART),q()}else S(g)})),I=e=>""==e?"":(0,i.normalize)(e).replace(/\\/g,"/"),U=e=>{const t=(0,i.resolve)("/",e.hostname,...e.pathname.replace(/[?#].*$/,"").replace(/^\/+/,"").split("/"));return{error:null,data:null,hasRun:!1,run:function(){return this.hasRun?Promise.resolve():new Promise((n=>(0,s.readFile)(t,((o,r)=>{if(this.hasRun=!0,!o||"EISDIR"!==o.code)return this.error=o,this.data=r,void n(void 0);(0,s.readdir)(t,((t,o)=>{this.error=t,this.data=o,t?n(void 0):Promise.all(o.map((t=>new Promise((n=>(0,s.lstat)((0,i.resolve)(e.pathname,t),((e,o)=>n([t,o,e])))))))).then((t=>{const o=t.filter((e=>!e[2]&&e[1].isDirectory())).concat(t.filter((e=>!e[2]&&e[1].isFile())));this.data=`${L(128194,"directory",e.href)}<p>Directory content of <i>${e.href.replace(/\//g,"&#x002F;")}</i></p><ul class="list-group"><li class="list-group-item">&#x1F4C1;<a href="${e.pathname.endsWith("/")?"..":"."}">&lt;parent&gt;</a></li>${o.filter((e=>!e[2])).map((t=>`<li class="list-group-item">&#x${(t[1].isDirectory()?128193:128196).toString(16)};<a href="${e.pathname.endsWith("/")?"":`${e.pathname.split("/").slice(-1)[0]}/`}${t[0]}">${t[0]}</a></li>`)).join("\n")}</li></ul></body></html>`,n(void 0)}))}))}))))},events:{},on:function(e,n){return this.events[e]=n,this.run().then((()=>{"response"===e&&this.events.response(t.endsWith(".svg")?{Server:"local","Content-Type":"image/svg+xml"}:{Server:"local"},0),"data"===e&&this.data&&(this.events.data(this.data),this.events.end()),"error"===e&&this.error&&this.events.error(this.error)})),this},end:function(){return this},request:function(){return this}}},T=e=>({error:null,data:null,run:function(){return new Promise((t=>{this.data=e,t(void 0)}))},events:{},on:function(e,t){return this.events[e]=t,this.run().then((()=>{"response"===e&&this.events.response({Server:"local","Content-Type":"text/html"},0),"data"===e&&this.data&&(this.events.data(this.data),this.events.end()),"error"===e&&this.error&&this.events.error(this.error)})),this},end:function(){return this},request:function(){return this}}),L=(e,t,n)=>`<!doctype html>\n<html lang="en">\n<head>\n<title>&#x${e.toString(16)}; local-traffic ${t} | ${n}</title>\n<link href="https://cdn.jsdelivr.net/npm/bootstrap@latest/dist/css/bootstrap.min.css" rel="stylesheet"/>\n<script src="https://cdn.jsdelivr.net/npm/jquery@latest/dist/jquery.min.js"><\/script>\n<script src="https://cdn.jsdelivr.net/npm/bootstrap@latest/dist/js/bootstrap.bundle.min.js"><\/script>\n</head>\n<body><div class="container"><h1>&#x${e.toString(16)}; local-traffic ${t}</h1>\n<br/>`,x=(e,t,n,o)=>`${L(128163,"error",e.message)}\n<p>An error happened while trying to proxy a remote exchange</p>\n<div class="alert alert-warning" role="alert">\n &#x24D8;&nbsp;This is not an error from the downstream service.\n</div>\n<div class="alert alert-danger" role="alert">\n<pre><code>${e.stack||`<i>${e.name} : ${e.message}</i>`}${e.errno?`<br/>(code : ${e.errno})`:""}</code></pre>\n</div>\nMore information about the request :\n<table class="table">\n <tbody>\n <tr>\n <td>phase</td>\n <td>${t}</td>\n </tr>\n <tr>\n <td>requested URL</td>\n <td>${n}</td>\n </tr>\n <tr>\n <td>downstream URL</td>\n <td>${o||"&lt;no-target-url&gt;"}</td>\n </tr>\n </tbody>\n</table>\n</div></body></html>`,H=(t,n,o)=>e(void 0,void 0,void 0,(function*(){var r,s;return(null!==(s=null===(r=n["content-encoding"])||void 0===r?void 0:r.toString())&&void 0!==s?s:"").split(",").reduce(((t,n)=>e(void 0,void 0,void 0,(function*(){const e=n.trim().toLowerCase(),o="gzip"===e||"x-gzip"===e?a.gunzip:"deflate"===e?a.inflate:"br"===e?a.brotliDecompress:"identity"===e||""===e?(e,t)=>{t(null,e)}:null;if(null===o)throw new Error(`${e} compression not supported by the proxy`);const r=yield t;return yield new Promise(((e,t)=>o(r,((n,o)=>{n&&t(n),e(o)}))))}))),Promise.resolve(t)).then((e=>{const t=e.length>1e7,r=["text/html","application/javascript","application/json"].some((e=>{var t;return(null!==(t=n["content-type"])&&void 0!==t?t:"").toString().includes(e)}));return!t&&(r||!/[^\x00-\x7F]/.test(e.toString()))?g.replaceResponseBodyUrls?C(e.toString(),o.direction,o.proxyHostnameAndPort).replace(/\?protocol=wss?%3A&hostname=[^&]+&port=[0-9]+&pathname=/g,`?protocol=ws${g.ssl?"s":""}%3A&hostname=${o.proxyHostname}&port=${g.port}&pathname=${encodeURIComponent(o.key.replace(/\/+$/,""))}`):e.toString():e})).then((e=>{var t,o;return(null!==(o=null===(t=n["content-encoding"])||void 0===t?void 0:t.toString())&&void 0!==o?o:"").split(",").reduce(((e,t)=>{const n=t.trim().toLowerCase(),o="gzip"===n||"x-gzip"===n?a.gzip:"deflate"===n?a.deflate:"br"===n?a.brotliCompress:"identity"===n||""===n?(e,t)=>{t(null,e)}:null;if(null===o)throw new Error(`${n} compression not supported by the proxy`);return e.then((e=>new Promise((t=>o(e,((e,n)=>{if(e)throw e;t(n)}))))))}),Promise.resolve(Buffer.from(e)))}))})),C=(e,t,n)=>Object.entries(g.mapping).map((([e,t])=>[e,"string"==typeof t?t:t.replaceBody])).reduce(((e,[o,r])=>r.startsWith("logs:")||r.startsWith("config:")||""!==o&&!o.match(/^[-a-zA-Z0-9()@:%_\+.~#?&//=]*$/)?e:t===u.INBOUND?e.replace(new RegExp(r.replace(/^(file|logs):\/\//,"").replace(/[*+?^${}()|[\]\\]/g,"").replace(/^https/,"https?")+"/*","ig"),`http${g.ssl?"s":""}://${n}${o.replace(/\/+$/,"")}/`):e.split(`http${g.ssl?"s":""}://${n}${o.replace(/\/+$/,"")}`).join(r)),e).split(`${n}/:`).join(`${n}:`),k=(e,t,n)=>{t.writeHead(e,void 0,{"content-type":"text/html","content-length":n.length}),t.end(n)},P=e=>{var t,n,o;const s=(null!==(o=null!==(n=null===(t=e.headers[":authority"])||void 0===t?void 0:t.toString())&&void 0!==n?n:e.headers.host)&&void 0!==o?o:"localhost").replace(/:.*/,""),a=e.headers[":authority"]||`${e.headers.host}${e.headers.host.match(/:[0-9]+$/)?"":80!==g.port||g.ssl?443===g.port&&g.ssl?"":`:${g.port}`:""}`,i=new r.URL(`http${g.ssl?"s":""}://${a}${e.url}`),l=i.href.substring(i.origin.length),[d,c]=Object.entries(Object.assign({},Object.assign({},...Object.entries(g.mapping).map((([e,t])=>({[e]:new r.URL(I("string"==typeof t?t:t.downstreamUrl))})))))).find((([e])=>l.match(RegExp(e.replace(/^\//,"^/")))))||[];return{proxyHostname:s,proxyHostnameAndPort:a,url:i,path:l,key:d,target:c}},q=()=>{y=(g.ssl?t.createSecureServer.bind(null,Object.assign(Object.assign({},g.ssl),{allowHTTP1:!0})):n.createServer)(((s,a)=>e(void 0,void 0,void 0,(function*(){var e,i,h,y;if(!s.headers.host&&!s.headers[":authority"])return void k(400,a,Buffer.from(x(new Error("client must supply a 'host' header"),"proxy",new r.URL(`http${g.ssl?"s":""}://unknowndomain${s.url}`))));const{proxyHostname:b,proxyHostnameAndPort:R,url:w,path:$,key:N,target:S}=P(s);if(!S)return void k(502,a,Buffer.from(x(new Error(`No mapping found in config file ${m}`),"proxy",w)));const B=S.host.replace(RegExp(/\/+$/),""),j=`${S.href.substring(8+S.host.length)}${I($.replace(RegExp(I(N)),""))}`.replace(/^\/*/,"/"),q=new r.URL(`${S.protocol}//${B}${j}`);let A=!g.dontUseHttp2Downstream;const D=(0,l.randomBytes)(20).toString("hex");E({level:"info",protocol:A?"HTTP/2":"HTTP1.1",method:s.method,upstreamPath:$,downstreamPath:q.href,randomId:D,uniqueHash:"N/A"});const W=d.hrtime.bigint();let M=null;const F="file:"===S.protocol?U(q):"logs:"===S.protocol?(e=>T(`${L(128250,"logs","")}\n<nav class="navbar navbar-expand-lg navbar-dark bg-primary nav-fill">\n <div class="container-fluid">\n <ul class="navbar-nav">\n <li class="nav-item">\n <a class="nav-link active" aria-current="page" href="javascript:show(0)">Access</a>\n </li>\n <li class="nav-item">\n <a class="nav-link" href="javascript:show(1)">Proxy</a>\n </li>\n </ul>\n <span class="navbar-text">\n Limit : <select id="limit" onchange="javascript:cleanup()"><option value="-1">0 (clear)</option><option value="10">10</option>\n <option value="50">50</option><option value="100">100</option><option value="200">200</option>\n <option selected="selected" value="500">500</option><option value="0">Infinity (discouraged)</option>\n </select> rows\n </span>\n </div>\n</nav>\n<table id="table-access" class="table table-striped" style="display: block; width: 100%; overflow-y: auto">\n <thead>\n <tr>\n <th scope="col">...</th>\n <th scope="col">Date</th>\n <th scope="col">Level</th>\n <th scope="col">Protocol</th>\n <th scope="col">Method</th>\n <th scope="col">Status</th>\n <th scope="col">Duration</th>\n <th scope="col">Upstream Path</th>\n <th scope="col">Downstream Path</th>\n </tr>\n </thead>\n <tbody id="access">\n </tbody>\n</table>\n<table id="table-proxy" class="table table-striped" style="display: none; width: 100%; overflow-y: auto">\n <thead>\n <tr>\n <th scope="col">Date</th>\n <th scope="col">Level</th>\n <th scope="col">Message</th>\n </tr>\n </thead>\n <tbody id="proxy">\n </tbody>\n</table>\n<script type="text/javascript">\n function start() {\n document.getElementById('table-access').style.height =\n (document.documentElement.clientHeight - 150) + 'px';\n const socket = new WebSocket("ws${g.ssl?"s":""}://${e}/local-traffic-logs");\n socket.onmessage = function(event) {\n let data = event.data\n let uniqueHash;\n try {\n const { uniqueHash: uniqueHash1, ...data1 } = JSON.parse(event.data);\n data = data1;\n uniqueHash = uniqueHash1;\n } catch(e) { }\n const time = new Date().toISOString().split('T')[1].replace('Z', '');\n const replay = uniqueHash ? '<button data-uniquehash="' + uniqueHash + '" onclick="javascript:replay(event)" ' +\n 'type="button" class="btn btn-primary"' + \n (uniqueHash === 'N/A' ? ' disabled="disabled"' : '') + '>&#x1F501;</button>' : '';\n if(data.statusCode && uniqueHash) {\n const color = Math.floor(data.statusCode / 100) === 1 ? "info" :\n Math.floor(data.statusCode / 100) === 2 ? "success" :\n Math.floor(data.statusCode / 100) === 3 ? "dark" :\n Math.floor(data.statusCode / 100) === 4 ? "warning" :\n Math.floor(data.statusCode / 100) === 5 ? "danger" :\n "secondary";\n const statusCodeColumn = document.querySelector("#event-" + data.randomId + " .statusCode");\n if (statusCodeColumn)\n statusCodeColumn.innerHTML = '<span class="badge bg-' + color + '">' + data.statusCode + '</span>';\n\n const durationColumn = document.querySelector("#event-" + data.randomId + " .duration");\n if (durationColumn) {\n const duration = data.duration > 10000 ? Math.floor(data.duration / 1000) + 's' :\n data.duration + 'ms';\n durationColumn.innerHTML = duration;\n }\n\n const protocolColumn = document.querySelector("#event-" + data.randomId + " .protocol");\n if (protocolColumn) {\n protocolColumn.innerHTML = data.protocol;\n }\n\n const replayColumn = document.querySelector("#event-" + data.randomId + " .replay");\n if (replayColumn) {\n replayColumn.innerHTML = replay;\n }\n } else if (uniqueHash) {\n document.getElementById("access")\n .insertAdjacentHTML('afterbegin', '<tr id="event-' + data.randomId + '">' +\n '<td scope="col" class="replay">' + replay + '</td>' +\n '<td scope="col">' + time + '</td>' +\n '<td scope="col">' + (data.level || 'info')+ '</td>' + \n '<td scope="col" class="protocol">' + data.protocol + '</td>' + \n '<td scope="col">' + data.method + '</td>' + \n '<td scope="col" class="statusCode"><span class="badge bg-secondary">...</span></td>' +\n '<td scope="col" class="duration">&#x23F1;</td>' +\n '<td scope="col">' + data.upstreamPath + '</td>' + \n '<td scope="col">' + data.downstreamPath + '</td>' + \n '</tr>');\n } else if(data.event) {\n document.getElementById("proxy")\n .insertAdjacentHTML('afterbegin', '<tr><td scope="col">' + time + '</td>' +\n '<td scope="col">' + (data.level || 'info')+ '</td>' + \n '<td scope="col">' + data.event + '</td></tr>');\n }\n cleanup();\n };\n socket.onerror = function(error) {\n console.log(\`[error] \${error}\`);\n setTimeout(start, 5000);\n };\n };\n function show(id) {\n [...document.querySelectorAll('table')].forEach((table, index) => {\n table.style.display = index === id ? 'block': 'none'\n });\n [...document.querySelectorAll('.navbar-nav .nav-item .nav-link')].forEach((link, index) => {\n if (index === id) { link.classList.add('active') } else link.classList.remove('active');\n });\n }\n function cleanup() {\n const currentLimit = parseInt(document.getElementById('limit').value)\n for (let table of ['access', 'proxy']) {\n while (currentLimit && document.getElementById(table).childNodes.length && \n document.getElementById(table).childNodes.length > currentLimit) {\n [...document.getElementById(table).childNodes].slice(-1)[0].remove();\n }\n }\n }\n function replay(event) {\n const uniqueHash = event.target.dataset.uniquehash;\n const { method, url, headers, body } = JSON.parse(atob(uniqueHash));\n fetch(url, {\n method,\n headers,\n body: !body.data || !body.data.length \n ? undefined\n : new TextDecoder().decode(new Int8Array(body.data))\n });\n }\n window.addEventListener("DOMContentLoaded", start);\n<\/script>\n</body></html>`))(R):"config:"===S.protocol?(e=>T(`${L(127899,"config","")}\n <link href="https://cdn.jsdelivr.net/npm/jsoneditor/dist/jsoneditor.min.css" rel="stylesheet" type="text/css">\n <script src="https://cdn.jsdelivr.net/npm/jsoneditor/dist/jsoneditor.min.js"><\/script>\n <div id="jsoneditor" style="width: 400px; height: 400px;"></div>\n <script>\n // create the editor\n const container = document.getElementById("jsoneditor")\n const options = {mode: "code", allowSchemaSuggestions: true, schema: {\n type: "object",\n properties: {\n ${Object.entries(Object.assign(Object.assign({},f),{ssl:{cert:"",key:""}})).map((([e,t])=>`${e}: {type: "${"number"==typeof t?"integer":"string"==typeof t?"string":"boolean"==typeof t?"boolean":"object"}"}`)).join(",\n ")}\n },\n required: [],\n additionalProperties: false\n }}\n\n function save() {\n socket.send(JSON.stringify(editor.get()));\n }\n\n const editor = new JSONEditor(container, options);\n let socket;\n const initialJson = ${JSON.stringify(g)}\n editor.set(initialJson)\n editor.validate();\n editor.aceEditor.commands.addCommand({\n name: 'save',\n bindKey: {win: 'Ctrl-S', mac: 'Command-S'},\n exec: save,\n });\n\n window.addEventListener("DOMContentLoaded", function() {\n document.getElementById('jsoneditor').style.height =\n (document.documentElement.clientHeight - 150) + 'px';\n document.getElementById('jsoneditor').style.width =\n parseInt(window.getComputedStyle(\n document.querySelector('.container')).maxWidth) + 'px';\n const saveButton = document.createElement('button');\n saveButton.addEventListener("click", save);\n saveButton.type="button";\n saveButton.classList.add("btn");\n saveButton.classList.add("btn-primary");\n saveButton.innerHTML="&#x1F4BE;";\n document.querySelector('.jsoneditor-menu')\n .appendChild(saveButton);\n socket = new WebSocket("ws${g.ssl?"s":""}://${e}/local-traffic-config");\n socket.onmessage = function(event) {\n editor.set(JSON.parse(event.data))\n editor.validate()\n }\n });\n <\/script>\n </body></html>`))(R):A?yield Promise.race([new Promise((e=>{const n=(0,t.connect)(q,{rejectUnauthorized:!1,protocol:S.protocol},((t,o)=>{A=A&&!!o.alpnProtocol,e(A?n:null)}));n.on("error",(e=>{M=A&&Buffer.from(x(e,"connection",w,q))}))})),new Promise((e=>setTimeout((()=>{A=!1,e(null)}),3e3)))]):null;M instanceof Buffer||(M=null);const _=null==s?void 0:s.readableLength,J=null===(e=null==s?void 0:s.stream)||void 0===e?void 0:e.readableLength;let z=null;const G=g.replaceRequestBodyUrls||v.length;if(G){const e=null!==(i=null==s?void 0:s.stream)&&void 0!==i?i:s;let t=Buffer.from([]);const n=!((g.ssl&&0===J||!g.ssl&&0===_)&&("0"===s.headers["content-length"]||void 0===s.headers["content-length"]));yield Promise.race([new Promise((e=>setTimeout(e,1e4))),new Promise((o=>{n||o(void 0),e.on("data",(e=>{t=Buffer.concat([t,e])})),e.on("end",o),e.on("error",o)}))]),n&&!t.length&&O(`body replacement error ${$.slice(-17)}`,c.WARNING,p.ERROR_4),z=yield H(t,s.headers,{proxyHostnameAndPort:R,proxyHostname:b,key:N,direction:u.OUTBOUND})}const K=Object.assign(Object.assign({},[...Object.entries(s.headers)].filter((([e])=>!["host","connection","keep-alive"].includes(e.toLowerCase()))).reduce(((e,[t,n])=>(e[t]=(e[t]||"")+(Array.isArray(n)?n:[n]).map((e=>e.replace(w.hostname,B))).join(", "),e)),{})),{origin:S.href,referer:q.toString(),"content-length":null!==(y=null!==(h=null==z?void 0:z.length)&&void 0!==h?h:s.headers["content-length"])&&void 0!==y?y:0,":authority":B,":method":s.method,":path":j,":scheme":S.protocol.replace(":","")}),Z=F&&!M&&F.request(K,{endStream:g.ssl?!(null==J||J):!_});null==Z||Z.on("error",(e=>{const t=-505===e.errno;M=Buffer.from(x(e,"stream"+(t?" (error -505 usually means that the downstream service does not support this http version)":""),w,q))}));const V={hostname:S.hostname,path:j,port:S.port,protocol:S.protocol,rejectUnauthorized:!1,method:s.method,headers:Object.assign(Object.assign({},Object.assign({},...Object.entries(K).filter((([e])=>!e.startsWith(":")&&"transfer-encoding"!==e.toLowerCase())).map((([e,t])=>({[e]:t}))))),{host:S.hostname})},Y=!M&&!A&&!["file:","logs:","config:"].includes(S.protocol)&&(yield new Promise((e=>{const t="https:"===S.protocol?(0,o.request)(V,e):(0,n.request)(V,e);t.on("error",(t=>{M=Buffer.from(x(t,"request",w,q)),e(null)})),G&&(t.write(z),t.end()),G||(s.on("data",(e=>t.write(e))),s.on("end",(()=>t.end())))})));if(M)return void k(502,a,M);M=null,g.ssl&&J&&Z&&(G&&(Z.write(z),Z.end()),G||(s.stream.on("data",(e=>{Z.write(e)})),s.stream.on("end",(()=>Z.end())))),!g.ssl&&_&&Z&&(G&&(Z.write(z),Z.end()),G||(s.on("data",(e=>{Z.write(e)})),s.on("end",(()=>Z.end()))));const{outboundResponseHeaders:Q}=yield new Promise((e=>Z?Z.on("response",(t=>{e({outboundResponseHeaders:t})})):e(!Z&&Y?{outboundResponseHeaders:Y.headers}:{outboundResponseHeaders:{}}))),X=Q.location?new r.URL(Q.location.startsWith("/")?`${S.href}${Q.location.replace(/^\/+/,"")}`:Q.location.replace(/^file:\/+/,"file:///").replace(/^(http)(s?):\/+/,"$1$2://")):null,ee=g.replaceResponseBodyUrls&&X?new r.URL(C(X.href,u.INBOUND,R).replace(/^(config:|logs:|file:)\/+/,"")):X,te=X?ee.origin!==X.origin||g.dontTranslateLocationHeader?ee:`${w.origin}${ee.href.substring(ee.origin.length)}`:X,ne=Z||Y,oe=null!=M?M:yield new Promise((e=>{let t=Buffer.alloc(0);ne?(ne.on("data",(e=>t=Buffer.concat([t,"string"==typeof e?Buffer.from(e):e]))),ne.on("end",(()=>{e(t)}))):e(t)})).then((e=>g.replaceResponseBodyUrls&&e.length?"config:"===S.protocol?e:H(e,Q,{proxyHostnameAndPort:R,proxyHostname:b,key:N,direction:u.INBOUND}).catch((e=>(k(502,a,Buffer.from(x(e,"stream",w,q))),Buffer.from("")))):e)),re=Object.assign(Object.assign({},Object.entries(Object.assign(Object.assign(Object.assign({},Q),g.replaceResponseBodyUrls?{"content-length":`${oe.byteLength}`}:{}),g.disableWebSecurity?{"content-security-policy":"report only","access-control-allow-headers":"*","access-control-allow-method":"*","access-control-allow-origin":"*"}:{})).filter((([e])=>!e.startsWith(":")&&"transfer-encoding"!==e.toLowerCase()&&"connection"!==e.toLowerCase()&&"keep-alive"!==e.toLowerCase())).reduce(((e,[t,n])=>{const o=B.split("").map(((e,t)=>B.substring(t).startsWith(".")&&B.substring(t))).filter((e=>e)),r=[B].concat(o).reduce(((e,t)=>(Array.isArray(e)?e:[e]).map((e=>"string"==typeof e?e.replace(`Domain=${t}`,`Domain=${w.hostname}`):e))),n);return e[t]=(e[t]||[]).concat(r),e}),{})),te?{location:[te]}:{});try{Object.entries(re).forEach((([e,t])=>t&&a.setHeader(e,t)))}catch(e){}const se=Q[":status"]||Y.statusCode||200;a.writeHead(se,g.ssl?void 0:Y.statusMessage||"Status read from http/2",re),oe?a.end(oe):a.end();const ae=d.hrtime.bigint();E({randomId:D,statusCode:se,protocol:A?"HTTP/2":"HTTP1.1",duration:Math.floor(Number(ae-W)/1e6),uniqueHash:Buffer.from(JSON.stringify({method:s.method,url:s.url,headers:Object.assign({},...Object.entries(s.headers).filter((([e])=>!e.startsWith(":"))).map((([e,t])=>({[e]:t})))),body:null==z?void 0:z.toJSON()})).toString("base64")})})))).addListener("error",(e=>{"EACCES"===e.code&&O("permission denied for this port",c.ERROR,p.NO),"EADDRINUSE"===e.code&&O("port is already used. NOT started",c.ERROR,p.ERROR_6)})).addListener("listening",(()=>{S(g)})).on("upgrade",((e,t)=>{var a,i,l,d;if(!g.websocket)return void t.end("HTTP/1.1 503 Service Unavailable\r\n\r\n");const{key:u,target:h,path:f}=P(e);if("/local-traffic-logs"===f)return $(t,e.headers["sec-websocket-key"]),t.on("close",(()=>{v=v.filter((e=>t!==e.stream))})),void v.push({stream:t,wantsMask:!(null!==(i=null===(a=e.headers["user-agent"])||void 0===a?void 0:a.toString())&&void 0!==i?i:"").includes("Chrome")});if("/local-traffic-config"===f){$(t,e.headers["sec-websocket-key"]),t.on("close",(()=>{b=b.filter((e=>t!==e.stream))}));let n=null;return t.on("data",(e=>{const t=((e,t)=>{var n;if(!t&&0==(1&e.readUInt8(0)))return{payloadLength:0,mask:[0,0,0,0],body:""};const o=t?0:e.readUInt8(1),r=o>>7,s=127&o,a=t?t.payloadLength:127!==s?s:e.readUInt8(2)<<8+e.readUInt8(3),i=t?t.mask:r?Array(4).fill(0).map(((t,n)=>e.readUInt8(n+4))):[0,0,0,0],l=t?0:r?8:4,d=Array(e.length-l).fill(0).map(((t,n)=>String.fromCharCode(e.readUInt8(n+l)^i[3&n]))).join("");return{payloadLength:a,mask:i,body:(null!==(n=null==t?void 0:t.body)&&void 0!==n?n:"").concat(d)}})(e,n);if(null===n&&t.body.length<t.payloadLength)n=t;else if(null!==n&&t.body.length>=t.payloadLength){n=null;const e=JSON.parse(t.body);(0,s.writeFile)(m,JSON.stringify(e,null,2),(e=>{e?O("config file NOT saved",c.ERROR,p.ERROR_4):O("config file saved... will reload",c.INFO,p.COLORED)}))}})),void b.push({stream:t,wantsMask:!(null!==(d=null===(l=e.headers["user-agent"])||void 0===l?void 0:l.toString())&&void 0!==d?d:"").includes("Chrome")})}const y=new r.URL(`${h.protocol}//${h.host}${e.url.endsWith("/_next/webpack-hmr")?e.url:e.url.replace(new RegExp(`^${u}`,"g"),"").replace(/^\/*/,"/")}`),R={hostname:y.hostname,path:y.pathname,port:y.port,protocol:y.protocol,rejectUnauthorized:!1,method:e.method,headers:e.headers,host:y.hostname},w="https:"===y.protocol?(0,o.request)(R):(0,n.request)(R);w.end(),w.on("error",(e=>{O("websocket request has errored "+(e.errno?`(${e.errno})`:""),c.WARNING,p.WEBSOCKET)})),w.on("upgrade",((e,n)=>{const o=`HTTP/${e.httpVersion} ${e.statusCode} ${e.statusMessage}\r\n${Object.entries(e.headers).flatMap((([e,t])=>(Array.isArray(t)?t:[t]).map((t=>[e,t])))).map((([e,t])=>`${e}: ${t}\r\n`)).join("")}\r\n`;t.write(o),t.allowHalfOpen=!0,n.allowHalfOpen=!0,n.on("data",(e=>t.write(e))),t.on("data",(e=>n.write(e))),n.on("error",(e=>{O("downstream socket has errored "+(e.errno?`(${e.errno})`:""),c.WARNING,p.WEBSOCKET)})),t.on("error",(e=>{O("upstream socket has errored "+(e.errno?`(${e.errno})`:""),c.WARNING,p.WEBSOCKET)}))}))})).listen(g.port)};process.on("error",(function(e){["EPIPE","ECONNRESET"].includes(e.code)&&O(`ignoring ${e.code} error`,c.WARNING,p.ERROR_5)})),B().then(q);
package/index.ts CHANGED
@@ -68,6 +68,17 @@ enum REPLACEMENT_DIRECTION {
68
68
  OUTBOUND = "OUTBOUND",
69
69
  }
70
70
 
71
+ interface WebsocketListener {
72
+ stream: Duplex;
73
+ wantsMask: boolean;
74
+ }
75
+
76
+ interface PartialRead {
77
+ payloadLength: number;
78
+ mask: number[];
79
+ body: string;
80
+ }
81
+
71
82
  interface LocalConfiguration {
72
83
  mapping?: {
73
84
  [subPath: string]: string | { replaceBody: string; downstreamUrl: string };
@@ -92,6 +103,7 @@ const filename = resolve(
92
103
  );
93
104
  const defaultConfig: LocalConfiguration = {
94
105
  mapping: {
106
+ "/config/": "config://",
95
107
  "/logs/": "logs://",
96
108
  },
97
109
  port: 8080,
@@ -106,7 +118,8 @@ const defaultConfig: LocalConfiguration = {
106
118
 
107
119
  let config: LocalConfiguration;
108
120
  let server: Server;
109
- let logsListeners: Duplex[] = [];
121
+ let logsListeners: WebsocketListener[] = [];
122
+ let configListeners: WebsocketListener[] = [];
110
123
  const getCurrentTime = (simpleLogs?: boolean) => {
111
124
  const date = new Date();
112
125
  return `${simpleLogs ? "" : "\u001b[36m"}${`${date.getHours()}`.padStart(
@@ -153,35 +166,112 @@ const log = (text: string, level?: LogLevel, emoji?: string) => {
153
166
  : text
154
167
  }`,
155
168
  );
156
- notifyLogsListener({
169
+ notifyLogsListeners({
157
170
  event: simpleLog,
158
171
  level: levelToString(level),
159
172
  });
160
173
  };
161
174
 
162
- const notifyLogsListener = (data: Record<string, unknown>) => {
163
- if (!logsListeners.length) return;
164
- const text = JSON.stringify(data);
175
+ const createWebsocketBufferFrom = (
176
+ text: string,
177
+ wantsMask: boolean,
178
+ ): Buffer => {
165
179
  const mask = Array(4)
166
180
  .fill(0)
167
- .map(() => Math.floor(Math.random() * (2 << 7)));
181
+ .map(() => (wantsMask ? Math.floor(Math.random() * (2 << 7)) : 0));
168
182
  const maskedTextBits = [...text.substring(0, 2 << 15)].map(
169
183
  (c, i) => c.charCodeAt(0) ^ mask[i & 3],
170
184
  );
171
185
  const length = Math.min((2 << 15) - 1, text.length);
172
186
  const header =
173
187
  text.length < (2 << 6) - 2
174
- ? Buffer.from(Uint8Array.from([(1 << 7) + 1, (1 << 7) + length]).buffer)
188
+ ? Buffer.from(
189
+ Uint8Array.from([(1 << 7) + 1, (wantsMask ? 1 << 7 : 0) + length])
190
+ .buffer,
191
+ )
175
192
  : Buffer.concat([
176
- Buffer.from(Uint8Array.from([(1 << 7) + 1, (2 << 7) - 2]).buffer),
193
+ Buffer.from(
194
+ Uint8Array.from([
195
+ (1 << 7) + 1,
196
+ ((1 << 7) - 2) | (wantsMask ? 1 << 7 : 0),
197
+ ]).buffer,
198
+ ),
177
199
  Buffer.from(Uint8Array.from([length >> 8]).buffer),
178
- Buffer.from(Uint8Array.from([length & ((2 << 7) - 1)]).buffer),
200
+ Buffer.from(Uint8Array.from([length & ((1 << 8) - 1)]).buffer),
179
201
  ]);
180
202
  const maskingKey = Buffer.from(Int8Array.from(mask).buffer);
181
203
  const payload = Buffer.from(Int8Array.from(maskedTextBits).buffer);
182
- const value = Buffer.concat([header, maskingKey, payload]);
183
- logsListeners.forEach(logsListener => {
184
- logsListener.write(value, "ascii", () => {});
204
+ return Buffer.concat(
205
+ wantsMask ? [header, maskingKey, payload] : [header, payload],
206
+ );
207
+ };
208
+
209
+ const readWebsocketBuffer = (
210
+ buffer: Buffer,
211
+ partialRead: PartialRead,
212
+ ): PartialRead => {
213
+ if (!partialRead && (buffer.readUInt8(0) & 1) === 0)
214
+ return { payloadLength: 0, mask: [0, 0, 0, 0], body: "" };
215
+ const headerSecondByte = partialRead ? 0 : buffer.readUInt8(1);
216
+ const hasMask = headerSecondByte >> 7;
217
+ const payloadLengthFirstByte = headerSecondByte & ((1 << 7) - 1);
218
+ const payloadLength = partialRead
219
+ ? partialRead.payloadLength
220
+ : payloadLengthFirstByte !== (1 << 7) - 1
221
+ ? payloadLengthFirstByte
222
+ : buffer.readUInt8(2) << (8 + buffer.readUInt8(3));
223
+ const mask = partialRead
224
+ ? partialRead.mask
225
+ : !hasMask
226
+ ? [0, 0, 0, 0]
227
+ : Array(4)
228
+ .fill(0)
229
+ .map((_, i) => buffer.readUInt8(i + 4));
230
+ const payloadStart = partialRead ? 0 : hasMask ? 8 : 4;
231
+ const body = Array(buffer.length - payloadStart)
232
+ .fill(0)
233
+ .map((_, i) =>
234
+ String.fromCharCode(buffer.readUInt8(i + payloadStart) ^ mask[i & 3]),
235
+ )
236
+ .join("");
237
+ return { payloadLength, mask, body: (partialRead?.body ?? "").concat(body) };
238
+ };
239
+
240
+ const acknowledgeWebsocket = (socket: Duplex, key: string) => {
241
+ const shasum = createHash("sha1");
242
+ shasum.update(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11");
243
+ const accept = shasum.digest("base64");
244
+ socket.allowHalfOpen = true;
245
+ socket.write(
246
+ "HTTP/1.1 101 Switching Protocols\r\n" +
247
+ `date: ${new Date().toUTCString()}\r\n` +
248
+ "connection: upgrade\r\n" +
249
+ "upgrade: websocket\r\n" +
250
+ "server: local\r\n" +
251
+ `sec-websocket-accept: ${accept}\r\n` +
252
+ "\r\n",
253
+ );
254
+ };
255
+
256
+ const notifyConfigListeners = (data: Record<string, unknown>) =>
257
+ notifyListeners(data, configListeners);
258
+ const notifyLogsListeners = (data: Record<string, unknown>) =>
259
+ notifyListeners(data, logsListeners);
260
+ const notifyListeners = (
261
+ data: Record<string, unknown>,
262
+ listeners: WebsocketListener[],
263
+ ) => {
264
+ if (!listeners.length) return;
265
+ const text = JSON.stringify(data);
266
+ const wantsMask = new Set(listeners.map(listener => listener.wantsMask));
267
+ const bufferWithoutMask =
268
+ wantsMask.has(false) && createWebsocketBufferFrom(text, false);
269
+ const bufferWithMask =
270
+ wantsMask.has(true) && createWebsocketBufferFrom(text, true);
271
+ listeners.forEach(listener => {
272
+ listener.wantsMask
273
+ ? listener.stream.write(bufferWithMask, "ascii", () => {})
274
+ : listener.stream.write(bufferWithoutMask, "ascii", () => {});
185
275
  });
186
276
  };
187
277
 
@@ -207,6 +297,7 @@ const quickStatus = (thisConfig: LocalConfiguration) => {
207
297
  config.disableWebSecurity ? EMOJIS.NO : EMOJIS.SHIELD
208
298
  }⎹\u001b[0m`,
209
299
  );
300
+ notifyConfigListeners(thisConfig as Record<string, unknown>);
210
301
  };
211
302
 
212
303
  const load = async (firstTime: boolean = true) =>
@@ -248,12 +339,16 @@ const load = async (firstTime: boolean = true) =>
248
339
  firstTime &&
249
340
  filename === userHomeConfigFile
250
341
  ) {
251
- writeFile(filename, JSON.stringify(defaultConfig), fileWriteErr => {
252
- if (fileWriteErr)
253
- log("config file NOT created", LogLevel.ERROR, EMOJIS.ERROR_4);
254
- else log("config file created", LogLevel.INFO, EMOJIS.COLORED);
255
- resolve(config);
256
- });
342
+ writeFile(
343
+ filename,
344
+ JSON.stringify(defaultConfig, null, 2),
345
+ fileWriteErr => {
346
+ if (fileWriteErr)
347
+ log("config file NOT created", LogLevel.ERROR, EMOJIS.ERROR_4);
348
+ else log("config file created", LogLevel.INFO, EMOJIS.COLORED);
349
+ resolve(config);
350
+ },
351
+ );
257
352
  } else resolve(config);
258
353
  }),
259
354
  ).then(() => {
@@ -368,11 +463,12 @@ const onWatch = async () => {
368
463
  ) {
369
464
  log(`restarting server`, LogLevel.INFO, EMOJIS.RESTART);
370
465
  await Promise.all(
371
- logsListeners.map(
372
- logsListener => new Promise(resolve => logsListener.end(resolve)),
373
- ),
466
+ logsListeners
467
+ .concat(configListeners)
468
+ .map(listener => new Promise(resolve => listener.stream.end(resolve))),
374
469
  );
375
470
  logsListeners = [];
471
+ configListeners = [];
376
472
  const stopped = await Promise.race([
377
473
  new Promise(resolve =>
378
474
  !server ? resolve(void 0) : server.close(resolve),
@@ -513,13 +609,48 @@ const fileRequest = (url: URL): ClientHttp2Session => {
513
609
  } as unknown as ClientHttp2Session;
514
610
  };
515
611
 
516
- const logsPage = (proxyHostnameAndPort: string): ClientHttp2Session =>
612
+ const staticPage = (data: string): ClientHttp2Session =>
517
613
  ({
518
614
  error: null as Error,
519
615
  data: null as string | Buffer,
520
616
  run: function () {
521
617
  return new Promise(resolve => {
522
- this.data = `${header(0x1f4fa, "logs", "")}
618
+ this.data = data;
619
+ resolve(void 0);
620
+ });
621
+ },
622
+ events: {} as { [name: string]: (...any: any) => any },
623
+ on: function (name: string, action: (...any: any) => any) {
624
+ this.events[name] = action;
625
+ this.run().then(() => {
626
+ if (name === "response")
627
+ this.events["response"](
628
+ {
629
+ Server: "local",
630
+ "Content-Type": "text/html",
631
+ },
632
+ 0,
633
+ );
634
+ if (name === "data" && this.data) {
635
+ this.events["data"](this.data);
636
+ this.events["end"]();
637
+ }
638
+ if (name === "error" && this.error) {
639
+ this.events["error"](this.error);
640
+ }
641
+ });
642
+ return this;
643
+ },
644
+ end: function () {
645
+ return this;
646
+ },
647
+ request: function () {
648
+ return this;
649
+ },
650
+ } as unknown as ClientHttp2Session);
651
+
652
+ const logsPage = (proxyHostnameAndPort: string) =>
653
+ staticPage(`${header(0x1f4fa, "logs", "")}
523
654
  <nav class="navbar navbar-expand-lg navbar-dark bg-primary nav-fill">
524
655
  <div class="container-fluid">
525
656
  <ul class="navbar-nav">
@@ -570,8 +701,6 @@ const logsPage = (proxyHostnameAndPort: string): ClientHttp2Session =>
570
701
  function start() {
571
702
  document.getElementById('table-access').style.height =
572
703
  (document.documentElement.clientHeight - 150) + 'px';
573
- document.getElementById('table-proxy').style.height =
574
- (document.documentElement.clientHeight - 150) + 'px';
575
704
  const socket = new WebSocket("ws${
576
705
  config.ssl ? "s" : ""
577
706
  }://${proxyHostnameAndPort}/local-traffic-logs");
@@ -670,39 +799,77 @@ const logsPage = (proxyHostnameAndPort: string): ClientHttp2Session =>
670
799
  }
671
800
  window.addEventListener("DOMContentLoaded", start);
672
801
  </script>
673
- </body></html>`;
674
- resolve(void 0);
675
- });
676
- },
677
- events: {} as { [name: string]: (...any: any) => any },
678
- on: function (name: string, action: (...any: any) => any) {
679
- this.events[name] = action;
680
- this.run().then(() => {
681
- if (name === "response")
682
- this.events["response"](
683
- {
684
- Server: "local",
685
- "Content-Type": "text/html",
686
- },
687
- 0,
688
- );
689
- if (name === "data" && this.data) {
690
- this.events["data"](this.data);
691
- this.events["end"]();
692
- }
693
- if (name === "error" && this.error) {
694
- this.events["error"](this.error);
695
- }
696
- });
697
- return this;
698
- },
699
- end: function () {
700
- return this;
701
- },
702
- request: function () {
703
- return this;
704
- },
705
- } as unknown as ClientHttp2Session);
802
+ </body></html>`);
803
+
804
+ const configPage = (proxyHostnameAndPort: string) =>
805
+ staticPage(`${header(0x1f39b, "config", "")}
806
+ <link href="https://cdn.jsdelivr.net/npm/jsoneditor/dist/jsoneditor.min.css" rel="stylesheet" type="text/css">
807
+ <script src="https://cdn.jsdelivr.net/npm/jsoneditor/dist/jsoneditor.min.js"></script>
808
+ <div id="jsoneditor" style="width: 400px; height: 400px;"></div>
809
+ <script>
810
+ // create the editor
811
+ const container = document.getElementById("jsoneditor")
812
+ const options = {mode: "code", allowSchemaSuggestions: true, schema: {
813
+ type: "object",
814
+ properties: {
815
+ ${Object.entries({ ...defaultConfig, ssl: { cert: "", key: "" } })
816
+ .map(
817
+ ([property, exampleValue]) =>
818
+ `${property}: {type: "${
819
+ typeof exampleValue === "number"
820
+ ? "integer"
821
+ : typeof exampleValue === "string"
822
+ ? "string"
823
+ : typeof exampleValue === "boolean"
824
+ ? "boolean"
825
+ : "object"
826
+ }"}`,
827
+ )
828
+ .join(",\n ")}
829
+ },
830
+ required: [],
831
+ additionalProperties: false
832
+ }}
833
+
834
+ function save() {
835
+ socket.send(JSON.stringify(editor.get()));
836
+ }
837
+
838
+ const editor = new JSONEditor(container, options);
839
+ let socket;
840
+ const initialJson = ${JSON.stringify(config)}
841
+ editor.set(initialJson)
842
+ editor.validate();
843
+ editor.aceEditor.commands.addCommand({
844
+ name: 'save',
845
+ bindKey: {win: 'Ctrl-S', mac: 'Command-S'},
846
+ exec: save,
847
+ });
848
+
849
+ window.addEventListener("DOMContentLoaded", function() {
850
+ document.getElementById('jsoneditor').style.height =
851
+ (document.documentElement.clientHeight - 150) + 'px';
852
+ document.getElementById('jsoneditor').style.width =
853
+ parseInt(window.getComputedStyle(
854
+ document.querySelector('.container')).maxWidth) + 'px';
855
+ const saveButton = document.createElement('button');
856
+ saveButton.addEventListener("click", save);
857
+ saveButton.type="button";
858
+ saveButton.classList.add("btn");
859
+ saveButton.classList.add("btn-primary");
860
+ saveButton.innerHTML="&#x1F4BE;";
861
+ document.querySelector('.jsoneditor-menu')
862
+ .appendChild(saveButton);
863
+ socket = new WebSocket("ws${
864
+ config.ssl ? "s" : ""
865
+ }://${proxyHostnameAndPort}/local-traffic-config");
866
+ socket.onmessage = function(event) {
867
+ editor.set(JSON.parse(event.data))
868
+ editor.validate()
869
+ }
870
+ });
871
+ </script>
872
+ </body></html>`);
706
873
 
707
874
  const header = (
708
875
  icon: number,
@@ -875,6 +1042,7 @@ const replaceTextUsingMapping = (
875
1042
  .reduce(
876
1043
  (inProgress, [path, value]) =>
877
1044
  value.startsWith("logs:") ||
1045
+ value.startsWith("config:") ||
878
1046
  (path !== "" && !path.match(/^[-a-zA-Z0-9()@:%_\+.~#?&//=]*$/))
879
1047
  ? inProgress
880
1048
  : direction === REPLACEMENT_DIRECTION.INBOUND
@@ -1017,7 +1185,7 @@ const start = () => {
1017
1185
 
1018
1186
  let http2IsSupported = !config.dontUseHttp2Downstream;
1019
1187
  const randomId = randomBytes(20).toString("hex");
1020
- notifyLogsListener({
1188
+ notifyLogsListeners({
1021
1189
  level: "info",
1022
1190
  protocol: http2IsSupported ? "HTTP/2" : "HTTP1.1",
1023
1191
  method: inboundRequest.method,
@@ -1035,6 +1203,8 @@ const start = () => {
1035
1203
  ? fileRequest(targetUrl)
1036
1204
  : target.protocol === "logs:"
1037
1205
  ? logsPage(proxyHostnameAndPort)
1206
+ : target.protocol === "config:"
1207
+ ? configPage(proxyHostnameAndPort)
1038
1208
  : !http2IsSupported
1039
1209
  ? null
1040
1210
  : await Promise.race([
@@ -1202,7 +1372,7 @@ const start = () => {
1202
1372
  const outboundHttp1Response: IncomingMessage =
1203
1373
  !error &&
1204
1374
  !http2IsSupported &&
1205
- !["file:", "logs:"].includes(target.protocol) &&
1375
+ !["file:", "logs:", "config:"].includes(target.protocol) &&
1206
1376
  (await new Promise(resolve => {
1207
1377
  const outboundHttp1Request: ClientRequest =
1208
1378
  target.protocol === "https:"
@@ -1304,7 +1474,7 @@ const start = () => {
1304
1474
  redirectUrl.href,
1305
1475
  REPLACEMENT_DIRECTION.INBOUND,
1306
1476
  proxyHostnameAndPort,
1307
- ).replace(/^(logs:|file:)\/+/, ""),
1477
+ ).replace(/^(config:|logs:|file:)\/+/, ""),
1308
1478
  );
1309
1479
  const translatedReplacedRedirectUrl = !redirectUrl
1310
1480
  ? redirectUrl
@@ -1341,6 +1511,7 @@ const start = () => {
1341
1511
  }).then((payloadBuffer: Buffer) => {
1342
1512
  if (!config.replaceResponseBodyUrls) return payloadBuffer;
1343
1513
  if (!payloadBuffer.length) return payloadBuffer;
1514
+ if (target.protocol === "config:") return payloadBuffer;
1344
1515
 
1345
1516
  return replaceBody(payloadBuffer, outboundResponseHeaders, {
1346
1517
  proxyHostnameAndPort,
@@ -1437,7 +1608,7 @@ const start = () => {
1437
1608
  else inboundResponse.end();
1438
1609
  const endTime = hrtime.bigint();
1439
1610
 
1440
- notifyLogsListener({
1611
+ notifyLogsListeners({
1441
1612
  randomId,
1442
1613
  statusCode,
1443
1614
  protocol: http2IsSupported ? "HTTP/2" : "HTTP1.1",
@@ -1485,28 +1656,67 @@ const start = () => {
1485
1656
  } = determineMapping(request);
1486
1657
 
1487
1658
  if (path === "/local-traffic-logs") {
1488
- const shasum = createHash("sha1");
1489
- shasum.update(
1490
- request.headers["sec-websocket-key"] +
1491
- "258EAFA5-E914-47DA-95CA-C5AB0DC85B11",
1492
- );
1493
- const accept = shasum.digest("base64");
1494
- upstreamSocket.allowHalfOpen = true;
1495
- upstreamSocket.write(
1496
- "HTTP/1.1 101 Switching Protocols\r\n" +
1497
- `date: ${new Date().toUTCString()}\r\n` +
1498
- "connection: upgrade\r\n" +
1499
- "upgrade: websocket\r\n" +
1500
- "server: local\r\n" +
1501
- `sec-websocket-accept: ${accept}\r\n` +
1502
- "\r\n",
1659
+ acknowledgeWebsocket(
1660
+ upstreamSocket,
1661
+ request.headers["sec-websocket-key"],
1503
1662
  );
1504
1663
  upstreamSocket.on("close", () => {
1505
1664
  logsListeners = logsListeners.filter(
1506
- oneLogsListener => upstreamSocket !== oneLogsListener,
1665
+ oneLogsListener => upstreamSocket !== oneLogsListener.stream,
1666
+ );
1667
+ });
1668
+ logsListeners.push({
1669
+ stream: upstreamSocket,
1670
+ wantsMask: !(
1671
+ request.headers["user-agent"]?.toString() ?? ""
1672
+ ).includes("Chrome"),
1673
+ });
1674
+ return;
1675
+ }
1676
+
1677
+ if (path === "/local-traffic-config") {
1678
+ acknowledgeWebsocket(
1679
+ upstreamSocket,
1680
+ request.headers["sec-websocket-key"],
1681
+ );
1682
+ upstreamSocket.on("close", () => {
1683
+ configListeners = configListeners.filter(
1684
+ oneConfigListener => upstreamSocket !== oneConfigListener.stream,
1507
1685
  );
1508
1686
  });
1509
- logsListeners.push(upstreamSocket);
1687
+ let partialRead = null;
1688
+ upstreamSocket.on("data", buffer => {
1689
+ const read = readWebsocketBuffer(buffer, partialRead);
1690
+ if (partialRead === null && read.body.length < read.payloadLength) {
1691
+ partialRead = read;
1692
+ } else if (
1693
+ partialRead !== null &&
1694
+ read.body.length >= read.payloadLength
1695
+ ) {
1696
+ partialRead = null;
1697
+ const newConfig = JSON.parse(read.body);
1698
+ writeFile(
1699
+ filename,
1700
+ JSON.stringify(newConfig, null, 2),
1701
+ fileWriteErr => {
1702
+ if (fileWriteErr)
1703
+ log("config file NOT saved", LogLevel.ERROR, EMOJIS.ERROR_4);
1704
+ else
1705
+ log(
1706
+ "config file saved... will reload",
1707
+ LogLevel.INFO,
1708
+ EMOJIS.COLORED,
1709
+ );
1710
+ },
1711
+ );
1712
+ }
1713
+ });
1714
+ configListeners.push({
1715
+ stream: upstreamSocket,
1716
+ wantsMask: !(
1717
+ request.headers["user-agent"]?.toString() ?? ""
1718
+ ).includes("Chrome"),
1719
+ });
1510
1720
  return;
1511
1721
  }
1512
1722
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "local-traffic",
3
- "version": "0.0.61",
3
+ "version": "0.0.63",
4
4
  "main": "index.ts",
5
5
  "private": false,
6
6
  "keywords": [