local-traffic 0.0.62 → 0.0.64

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}`),w({event:o,level:b(t)})},O=(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])},w=e=>{if(!v.length)return;const t=JSON.stringify(e),n=new Set(v.map((e=>e.wantsMask))),o=n.has(!1)&&O(t,!1),r=n.has(!0)&&O(t,!0);v.forEach((e=>{e.wantsMask?e.stream.write(r,"ascii",(()=>{})):e.stream.write(o,"ascii",(()=>{}))}))},$=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}⎹`)},E=(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,N)}))})),N=()=>e(void 0,void 0,void 0,(function*(){const e=Object.assign({},g);if(yield E(!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.stream.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),C()}else $(g)})),S=e=>""==e?"":(0,i.normalize)(e).replace(/\\/g,"/"),T=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=`${H(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}}},B=e=>({error:null,data:null,run:function(){return new Promise((t=>{this.data=`${H(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}}),H=(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/>`,I=(e,t,n,o)=>`${H(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>`,U=(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?x(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)))}))})),x=(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}:`),j=(e,t,n)=>{t.writeHead(e,void 0,{"content-type":"text/html","content-length":n.length}),t.end(n)},L=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(S("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}},C=()=>{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 j(400,a,Buffer.from(I(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:O,path:$,key:E,target:N}=L(s);if(!N)return void j(502,a,Buffer.from(I(new Error(`No mapping found in config file ${m}`),"proxy",O)));const H=N.host.replace(RegExp(/\/+$/),""),C=`${N.href.substring(8+N.host.length)}${S($.replace(RegExp(S(E)),""))}`.replace(/^\/*/,"/"),P=new r.URL(`${N.protocol}//${H}${C}`);let A=!g.dontUseHttp2Downstream;const D=(0,l.randomBytes)(20).toString("hex");w({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:"===N.protocol?T(P):"logs:"===N.protocol?B(b):A?yield Promise.race([new Promise((e=>{const n=(0,t.connect)(P,{rejectUnauthorized:!1,protocol:N.protocol},((t,o)=>{A=A&&!!o.alpnProtocol,e(A?n:null)}));n.on("error",(e=>{k=A&&Buffer.from(I(e,"connection",O,P))}))})),new Promise((e=>setTimeout((()=>{A=!1,e(null)}),3e3)))]):null;k instanceof Buffer||(k=null);const M=null==s?void 0:s.readableLength,F=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===F||!g.ssl&&0===M)&&("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 U(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(O.hostname,H))).join(", "),e)),{})),{origin:N.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":H,":method":s.method,":path":C,":scheme":N.protocol.replace(":","")}),J=W&&!k&&W.request(G,{endStream:g.ssl?!(null==F||F):!M});null==J||J.on("error",(e=>{const t=-505===e.errno;k=Buffer.from(I(e,"stream"+(t?" (error -505 usually means that the downstream service does not support this http version)":""),O,P))}));const K={hostname:N.hostname,path:C,port:N.port,protocol:N.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:N.hostname})},Z=!k&&!A&&!["file:","logs:"].includes(N.protocol)&&(yield new Promise((e=>{const t="https:"===N.protocol?(0,o.request)(K,e):(0,n.request)(K,e);t.on("error",(t=>{k=Buffer.from(I(t,"request",O,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 j(502,a,k);k=null,g.ssl&&F&&J&&(z&&(J.write(_),J.end()),z||(s.stream.on("data",(e=>{J.write(e)})),s.stream.on("end",(()=>J.end())))),!g.ssl&&M&&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("/")?`${N.href}${V.location.replace(/^\/+/,"")}`:V.location.replace(/^file:\/+/,"file:///").replace(/^(http)(s?):\/+/,"$1$2://")):null,Q=g.replaceResponseBodyUrls&&Y?new r.URL(x(Y.href,u.INBOUND,b).replace(/^(logs:|file:)\/+/,"")):Y,X=Y?Q.origin!==Y.origin||g.dontTranslateLocationHeader?Q:`${O.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?U(e,V,{proxyHostnameAndPort:b,proxyHostname:y,key:E,direction:u.INBOUND}).catch((e=>(j(502,a,Buffer.from(I(e,"stream",O,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=H.split("").map(((e,t)=>H.substring(t).startsWith(".")&&H.substring(t))).filter((e=>e)),r=[H].concat(o).reduce(((e,t)=>(Array.isArray(e)?e:[e]).map((e=>"string"==typeof e?e.replace(`Domain=${t}`,`Domain=${O.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();w({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",(()=>{$(g)})).on("upgrade",((e,t)=>{var s,a;if(!g.websocket)return void t.end("HTTP/1.1 503 Service Unavailable\r\n\r\n");const{key:i,target:c,path:u}=L(e);if("/local-traffic-logs"===u){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.stream))})),void v.push({stream:t,wantsMask:!(null!==(a=null===(s=e.headers["user-agent"])||void 0===s?void 0:s.toString())&&void 0!==a?a:"").includes("Chrome")})}const h=new r.URL(`${c.protocol}//${c.host}${e.url.endsWith("/_next/webpack-hmr")?e.url:e.url.replace(new RegExp(`^${i}`,"g"),"").replace(/^\/*/,"/")}`),m={hostname:h.hostname,path:h.pathname,port:h.port,protocol:h.protocol,rejectUnauthorized:!1,method:e.method,headers:e.headers,host:h.hostname},f="https:"===h.protocol?(0,o.request)(m):(0,n.request)(m);f.end(),f.on("error",(e=>{R("websocket request has errored "+(e.errno?`(${e.errno})`:""),d.WARNING,p.WEBSOCKET)})),f.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)})),E().then(C);
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(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: { stream: Duplex; wantsMask: boolean }[] = [];
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,7 +166,7 @@ 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
  });
@@ -184,7 +197,7 @@ const createWebsocketBufferFrom = (
184
197
  ]).buffer,
185
198
  ),
186
199
  Buffer.from(Uint8Array.from([length >> 8]).buffer),
187
- Buffer.from(Uint8Array.from([length & ((2 << 7) - 1)]).buffer),
200
+ Buffer.from(Uint8Array.from([length & ((1 << 8) - 1)]).buffer),
188
201
  ]);
189
202
  const maskingKey = Buffer.from(Int8Array.from(mask).buffer);
190
203
  const payload = Buffer.from(Int8Array.from(maskedTextBits).buffer);
@@ -193,20 +206,72 @@ const createWebsocketBufferFrom = (
193
206
  );
194
207
  };
195
208
 
196
- const notifyLogsListener = (data: Record<string, unknown>) => {
197
- if (!logsListeners.length) return;
198
- const text = JSON.stringify(data);
199
- const wantsMask = new Set(
200
- logsListeners.map(logsListener => logsListener.wantsMask),
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",
201
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));
202
267
  const bufferWithoutMask =
203
268
  wantsMask.has(false) && createWebsocketBufferFrom(text, false);
204
269
  const bufferWithMask =
205
270
  wantsMask.has(true) && createWebsocketBufferFrom(text, true);
206
- logsListeners.forEach(logsListener => {
207
- logsListener.wantsMask
208
- ? logsListener.stream.write(bufferWithMask, "ascii", () => {})
209
- : logsListener.stream.write(bufferWithoutMask, "ascii", () => {});
271
+ listeners.forEach(listener => {
272
+ listener.wantsMask
273
+ ? listener.stream.write(bufferWithMask, "ascii", () => {})
274
+ : listener.stream.write(bufferWithoutMask, "ascii", () => {});
210
275
  });
211
276
  };
212
277
 
@@ -232,6 +297,7 @@ const quickStatus = (thisConfig: LocalConfiguration) => {
232
297
  config.disableWebSecurity ? EMOJIS.NO : EMOJIS.SHIELD
233
298
  }⎹\u001b[0m`,
234
299
  );
300
+ notifyConfigListeners(thisConfig as Record<string, unknown>);
235
301
  };
236
302
 
237
303
  const load = async (firstTime: boolean = true) =>
@@ -273,12 +339,16 @@ const load = async (firstTime: boolean = true) =>
273
339
  firstTime &&
274
340
  filename === userHomeConfigFile
275
341
  ) {
276
- writeFile(filename, JSON.stringify(defaultConfig), fileWriteErr => {
277
- if (fileWriteErr)
278
- log("config file NOT created", LogLevel.ERROR, EMOJIS.ERROR_4);
279
- else log("config file created", LogLevel.INFO, EMOJIS.COLORED);
280
- resolve(config);
281
- });
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
+ );
282
352
  } else resolve(config);
283
353
  }),
284
354
  ).then(() => {
@@ -393,12 +463,12 @@ const onWatch = async () => {
393
463
  ) {
394
464
  log(`restarting server`, LogLevel.INFO, EMOJIS.RESTART);
395
465
  await Promise.all(
396
- logsListeners.map(
397
- logsListener =>
398
- new Promise(resolve => logsListener.stream.end(resolve)),
399
- ),
466
+ logsListeners
467
+ .concat(configListeners)
468
+ .map(listener => new Promise(resolve => listener.stream.end(resolve))),
400
469
  );
401
470
  logsListeners = [];
471
+ configListeners = [];
402
472
  const stopped = await Promise.race([
403
473
  new Promise(resolve =>
404
474
  !server ? resolve(void 0) : server.close(resolve),
@@ -539,13 +609,48 @@ const fileRequest = (url: URL): ClientHttp2Session => {
539
609
  } as unknown as ClientHttp2Session;
540
610
  };
541
611
 
542
- const logsPage = (proxyHostnameAndPort: string): ClientHttp2Session =>
612
+ const staticPage = (data: string): ClientHttp2Session =>
543
613
  ({
544
614
  error: null as Error,
545
615
  data: null as string | Buffer,
546
616
  run: function () {
547
617
  return new Promise(resolve => {
548
- 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", "")}
549
654
  <nav class="navbar navbar-expand-lg navbar-dark bg-primary nav-fill">
550
655
  <div class="container-fluid">
551
656
  <ul class="navbar-nav">
@@ -596,8 +701,6 @@ const logsPage = (proxyHostnameAndPort: string): ClientHttp2Session =>
596
701
  function start() {
597
702
  document.getElementById('table-access').style.height =
598
703
  (document.documentElement.clientHeight - 150) + 'px';
599
- document.getElementById('table-proxy').style.height =
600
- (document.documentElement.clientHeight - 150) + 'px';
601
704
  const socket = new WebSocket("ws${
602
705
  config.ssl ? "s" : ""
603
706
  }://${proxyHostnameAndPort}/local-traffic-logs");
@@ -696,39 +799,77 @@ const logsPage = (proxyHostnameAndPort: string): ClientHttp2Session =>
696
799
  }
697
800
  window.addEventListener("DOMContentLoaded", start);
698
801
  </script>
699
- </body></html>`;
700
- resolve(void 0);
701
- });
702
- },
703
- events: {} as { [name: string]: (...any: any) => any },
704
- on: function (name: string, action: (...any: any) => any) {
705
- this.events[name] = action;
706
- this.run().then(() => {
707
- if (name === "response")
708
- this.events["response"](
709
- {
710
- Server: "local",
711
- "Content-Type": "text/html",
712
- },
713
- 0,
714
- );
715
- if (name === "data" && this.data) {
716
- this.events["data"](this.data);
717
- this.events["end"]();
718
- }
719
- if (name === "error" && this.error) {
720
- this.events["error"](this.error);
721
- }
722
- });
723
- return this;
724
- },
725
- end: function () {
726
- return this;
727
- },
728
- request: function () {
729
- return this;
730
- },
731
- } 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>`);
732
873
 
733
874
  const header = (
734
875
  icon: number,
@@ -901,6 +1042,7 @@ const replaceTextUsingMapping = (
901
1042
  .reduce(
902
1043
  (inProgress, [path, value]) =>
903
1044
  value.startsWith("logs:") ||
1045
+ value.startsWith("config:") ||
904
1046
  (path !== "" && !path.match(/^[-a-zA-Z0-9()@:%_\+.~#?&//=]*$/))
905
1047
  ? inProgress
906
1048
  : direction === REPLACEMENT_DIRECTION.INBOUND
@@ -1043,7 +1185,7 @@ const start = () => {
1043
1185
 
1044
1186
  let http2IsSupported = !config.dontUseHttp2Downstream;
1045
1187
  const randomId = randomBytes(20).toString("hex");
1046
- notifyLogsListener({
1188
+ notifyLogsListeners({
1047
1189
  level: "info",
1048
1190
  protocol: http2IsSupported ? "HTTP/2" : "HTTP1.1",
1049
1191
  method: inboundRequest.method,
@@ -1061,6 +1203,8 @@ const start = () => {
1061
1203
  ? fileRequest(targetUrl)
1062
1204
  : target.protocol === "logs:"
1063
1205
  ? logsPage(proxyHostnameAndPort)
1206
+ : target.protocol === "config:"
1207
+ ? configPage(proxyHostnameAndPort)
1064
1208
  : !http2IsSupported
1065
1209
  ? null
1066
1210
  : await Promise.race([
@@ -1228,7 +1372,7 @@ const start = () => {
1228
1372
  const outboundHttp1Response: IncomingMessage =
1229
1373
  !error &&
1230
1374
  !http2IsSupported &&
1231
- !["file:", "logs:"].includes(target.protocol) &&
1375
+ !["file:", "logs:", "config:"].includes(target.protocol) &&
1232
1376
  (await new Promise(resolve => {
1233
1377
  const outboundHttp1Request: ClientRequest =
1234
1378
  target.protocol === "https:"
@@ -1330,7 +1474,7 @@ const start = () => {
1330
1474
  redirectUrl.href,
1331
1475
  REPLACEMENT_DIRECTION.INBOUND,
1332
1476
  proxyHostnameAndPort,
1333
- ).replace(/^(logs:|file:)\/+/, ""),
1477
+ ).replace(/^(config:|logs:|file:)\/+/, ""),
1334
1478
  );
1335
1479
  const translatedReplacedRedirectUrl = !redirectUrl
1336
1480
  ? redirectUrl
@@ -1367,6 +1511,7 @@ const start = () => {
1367
1511
  }).then((payloadBuffer: Buffer) => {
1368
1512
  if (!config.replaceResponseBodyUrls) return payloadBuffer;
1369
1513
  if (!payloadBuffer.length) return payloadBuffer;
1514
+ if (target.protocol === "config:") return payloadBuffer;
1370
1515
 
1371
1516
  return replaceBody(payloadBuffer, outboundResponseHeaders, {
1372
1517
  proxyHostnameAndPort,
@@ -1463,7 +1608,7 @@ const start = () => {
1463
1608
  else inboundResponse.end();
1464
1609
  const endTime = hrtime.bigint();
1465
1610
 
1466
- notifyLogsListener({
1611
+ notifyLogsListeners({
1467
1612
  randomId,
1468
1613
  statusCode,
1469
1614
  protocol: http2IsSupported ? "HTTP/2" : "HTTP1.1",
@@ -1511,21 +1656,9 @@ const start = () => {
1511
1656
  } = determineMapping(request);
1512
1657
 
1513
1658
  if (path === "/local-traffic-logs") {
1514
- const shasum = createHash("sha1");
1515
- shasum.update(
1516
- request.headers["sec-websocket-key"] +
1517
- "258EAFA5-E914-47DA-95CA-C5AB0DC85B11",
1518
- );
1519
- const accept = shasum.digest("base64");
1520
- upstreamSocket.allowHalfOpen = true;
1521
- upstreamSocket.write(
1522
- "HTTP/1.1 101 Switching Protocols\r\n" +
1523
- `date: ${new Date().toUTCString()}\r\n` +
1524
- "connection: upgrade\r\n" +
1525
- "upgrade: websocket\r\n" +
1526
- "server: local\r\n" +
1527
- `sec-websocket-accept: ${accept}\r\n` +
1528
- "\r\n",
1659
+ acknowledgeWebsocket(
1660
+ upstreamSocket,
1661
+ request.headers["sec-websocket-key"],
1529
1662
  );
1530
1663
  upstreamSocket.on("close", () => {
1531
1664
  logsListeners = logsListeners.filter(
@@ -1541,6 +1674,49 @@ const start = () => {
1541
1674
  return;
1542
1675
  }
1543
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,
1685
+ );
1686
+ });
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 (read.body.length >= read.payloadLength) {
1693
+ partialRead = null;
1694
+ const newConfig = JSON.parse(read.body);
1695
+ writeFile(
1696
+ filename,
1697
+ JSON.stringify(newConfig, null, 2),
1698
+ fileWriteErr => {
1699
+ if (fileWriteErr)
1700
+ log("config file NOT saved", LogLevel.ERROR, EMOJIS.ERROR_4);
1701
+ else
1702
+ log(
1703
+ "config file saved... will reload",
1704
+ LogLevel.INFO,
1705
+ EMOJIS.COLORED,
1706
+ );
1707
+ },
1708
+ );
1709
+ }
1710
+ });
1711
+ configListeners.push({
1712
+ stream: upstreamSocket,
1713
+ wantsMask: !(
1714
+ request.headers["user-agent"]?.toString() ?? ""
1715
+ ).includes("Chrome"),
1716
+ });
1717
+ return;
1718
+ }
1719
+
1544
1720
  const target = new URL(
1545
1721
  `${targetWithForcedPrefix.protocol}//${targetWithForcedPrefix.host}${
1546
1722
  request.url.endsWith("/_next/webpack-hmr")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "local-traffic",
3
- "version": "0.0.62",
3
+ "version": "0.0.64",
4
4
  "main": "index.ts",
5
5
  "private": false,
6
6
  "keywords": [