local-traffic 0.0.45 → 0.0.47

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.
@@ -1,2 +1,2 @@
1
1
  #!/usr/bin/env node
2
- "use strict";Object.defineProperty(exports,"__esModule",{value:!0});const e=require("http2"),t=require("http"),r=require("https"),o=require("url"),n=require("fs"),s=require("zlib"),a=require("path");var i,l;!function(e){e[e.ERROR=124]="ERROR",e[e.INFO=93]="INFO",e[e.WARNING=172]="WARNING"}(i||(i={})),function(e){e.INBOUND="↘️ ",e.PORT="☎️ ",e.OUTBOUND="↗️ ",e.RULES="🔗",e.BODY_REPLACEMENT="✒️ ",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="☠️ "}(l||(l={}));const p=(0,a.resolve)(process.env.HOME,".local-traffic.json"),c=(0,a.resolve)(process.cwd(),process.argv.slice(-1)[0].endsWith(".json")?process.argv.slice(-1)[0]:p),d={mapping:{},port:8080,replaceResponseBodyUrls:!1,dontUseHttp2Downstream:!1,simpleLogs:!1,websocket:!1,disableWebSecurity:!1};let h,u;const m=(e,t,r)=>{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?"":""}`})(h?.simpleLogs)} ${h?.simpleLogs?e.replace(/⎸/g,"|").replace(/⎹/g,"|").replace(/\u001b\[[^m]*m/g,"").replace(new RegExp(l.INBOUND,"g"),"inbound:").replace(new RegExp(l.PORT,"g"),"port:").replace(new RegExp(l.OUTBOUND,"g"),"outbound:").replace(new RegExp(l.RULES,"g"),"rules:").replace(new RegExp(l.NO,"g"),"").replace(new RegExp(l.BODY_REPLACEMENT,"g"),"body replacement").replace(new RegExp(l.WEBSOCKET,"g"),"websocket").replace(new RegExp(l.SHIELD,"g"),"content-security").replace(/\|+/g,"|"):t?`[48;5;${t}m⎸ ${process.stdout.isTTY&&r||""} ${e.padEnd(40)} ⎹`:e}`)},g=e=>{m(`⎸${l.PORT} ${e.port.toString().padStart(5)} ⎸${l.INBOUND} ${e.ssl?"H/2 ":"H1.1"} ⎸${l.OUTBOUND} ${e.dontUseHttp2Downstream?"H1.1":"H/2 "}⎹⎸${l.RULES}${Object.keys(h.mapping).length.toString().padStart(3)}⎹⎸${h.replaceResponseBodyUrls?l.BODY_REPLACEMENT:l.NO}⎹⎸${h.websocket?l.WEBSOCKET:l.NO}⎹⎸${h.simpleLogs?l.NO:l.COLORED}⎹⎸${h.disableWebSecurity?l.NO:l.SHIELD}⎹`)},R=async(e=!0)=>new Promise((t=>(0,n.readFile)(c,((r,o)=>{r&&!e&&m("config error. Using default value",i.ERROR,l.ERROR_1);try{h=Object.assign({},d,JSON.parse((o||"{}").toString()))}catch(e){return m("config syntax incorrect, aborting",i.ERROR,l.ERROR_2),h=h||{...d},void t(h)}h.mapping[""]||m('default mapping "" not provided.',i.WARNING,l.ERROR_3),r&&"ENOENT"===r.code&&e&&c===p?(0,n.writeFile)(c,JSON.stringify(d),(e=>{e?m("config file NOT created",i.ERROR,l.ERROR_4):m("config file created",i.INFO,l.COLORED),t(h)})):t(h)})))).then((()=>{e&&(0,n.watchFile)(c,f)})),f=async()=>{const e={...h};return await R(!1),isNaN(h.port)||h.port>65535||h.port<0?(h=e,void m("port number invalid. Not refreshing",i.ERROR,l.PORT)):"object"!=typeof h.mapping?(h=e,void m("mapping should be an object. Aborting",i.ERROR,l.ERROR_5)):(h.replaceResponseBodyUrls!==e.replaceResponseBodyUrls&&m(`response body url ${h.replaceResponseBodyUrls?"":"NO "}replacement`,i.INFO,l.BODY_REPLACEMENT),h.dontUseHttp2Downstream!==e.dontUseHttp2Downstream&&m(`http/2 ${h.dontUseHttp2Downstream?"de":""}activated downstream`,i.INFO,l.OUTBOUND),h.disableWebSecurity!==e.disableWebSecurity&&m(`content security ${h.disableWebSecurity?"de":""}activated`,i.INFO,l.SHIELD),h.websocket!==e.websocket&&m(`websocket ${h.websocket?"":"de"}activated`,i.INFO,l.WEBSOCKET),h.simpleLogs!==e.simpleLogs&&m("simple logs "+(h.simpleLogs?"on":"off"),i.INFO,l.COLORED),Object.keys(h.mapping).join("\n")!==Object.keys(e.mapping).join("\n")&&m(`${Object.keys(h.mapping).length.toString().padStart(5)} loaded mapping rules`,i.INFO,l.RULES),h.port!==e.port&&m(`port changed from ${e.port} to ${h.port}`,i.INFO,l.PORT),h.ssl&&!e.ssl&&m("ssl configuration added",i.INFO,l.INBOUND),!h.ssl&&e.ssl&&m("ssl configuration removed",i.INFO,l.INBOUND),void(h.port!==e.port||JSON.stringify(h.ssl)!==JSON.stringify(e.ssl)?(await new Promise((e=>u?u.close(e):e(void 0))),v()):g(h)))},$=e=>""==e?"":(0,a.normalize)(e).replace(/\\/g,"/"),O=e=>{const t=(0,a.resolve)("/",e.hostname,...e.pathname.replace(/[?#].*$/,"").replace(/^\/+/,"").split("/"));return{error:null,data:null,hasRun:!1,run:function(){return this.hasRun?Promise.resolve():new Promise((r=>(0,n.readFile)(t,((o,s)=>{if(this.hasRun=!0,!o||"EISDIR"!==o.code)return this.error=o,this.data=s,void r(void 0);(0,n.readdir)(t,((t,o)=>{this.error=t,this.data=o,t?r(void 0):Promise.all(o.map((t=>new Promise((r=>(0,n.lstat)((0,a.resolve)(e.pathname,t),((e,o)=>r([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=`${w(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>`,r(void 0)}))}))}))))},events:{},on:function(e,r){return this.events[e]=r,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}}},w=(e,t,r)=>`<!doctype html>\n<html lang="en">\n<head>\n<title>&#x${e.toString(16)}; local-traffic ${t} | ${r}</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/>`,b=(e,t,r,o)=>`${w(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>${r}</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>`,y=(e,t,r)=>{t.writeHead(e,void 0,{"content-type":"text/html","content-length":r.length}),t.end(r)},E=e=>{const t=(e.headers[":authority"]?.toString()??e.headers.host??"localhost").replace(/:.*/,""),r=e.headers[":authority"]||`${e.headers.host}${e.headers.host.match(/:[0-9]+$/)?"":80!==h.port||h.ssl?443===h.port&&h.ssl?"":`:${h.port}`:""}`,n=new o.URL(`http${h.ssl?"s":""}://${r}${e.url}`),s=n.href.substring(n.origin.length),[a,i]=Object.entries({...Object.assign({},...Object.entries(h.mapping).map((([e,t])=>({[e]:new o.URL($(t))}))))}).find((([e])=>s.match(RegExp(e.replace(/^\//,"^/")))))||[];return{proxyHostname:t,proxyHostnameAndPort:r,url:n,path:s,key:a,target:i}},v=()=>{u=(h.ssl?e.createSecureServer.bind(null,{...h.ssl,allowHTTP1:!0}):t.createServer)((async(n,a)=>{if(!n.headers.host&&!n.headers[":authority"])return void y(400,a,Buffer.from(b(new Error("client must supply a 'host' header"),"proxy",new o.URL(`http${h.ssl?"s":""}://unknowndomain${n.url}`))));const{proxyHostname:i,proxyHostnameAndPort:l,url:p,path:d,key:u,target:m}=E(n);if(!m)return void y(502,a,Buffer.from(b(new Error(`No mapping found in config file ${c}`),"proxy",p)));const g=m.host.replace(RegExp(/\/+$/),""),R=`${m.href.substring("https://".length+m.host.length)}${$(d.replace(RegExp($(u)),""))}`.replace(/^\/*/,"/"),f=new o.URL(`${m.protocol}//${g}${R}`);let w=null,v=!h.dontUseHttp2Downstream;const N="file:"===m.protocol?O(f):v?await Promise.race([new Promise((t=>{const r=(0,e.connect)(f,{rejectUnauthorized:!1,protocol:m.protocol},((e,o)=>{v=v&&!!o.alpnProtocol,t(v?r:null)}));r.on("error",(e=>{w=v&&Buffer.from(b(e,"connection",p,f))}))})),new Promise((e=>setTimeout((()=>{v=!1,e(null)}),3e3)))]):null;w instanceof Buffer||(w=null);const S={...[...Object.entries(n.headers)].filter((([e])=>!["host","connection","keep-alive"].includes(e.toLowerCase()))).reduce(((e,[t,r])=>(e[t]=(e[t]||"")+(Array.isArray(r)?r:[r]).map((e=>e.replace(p.hostname,g))).join(", "),e)),{}),origin:m.href,referer:f.toString(),":authority":g,":method":n.method,":path":R,":scheme":m.protocol.replace(":","")},U=N&&!w&&N.request(S,{endStream:h.ssl?!(n?.stream?.readableLength??1):!n.readableLength});U?.on("error",(e=>{const t=-505===e.errno;w=Buffer.from(b(e,"stream"+(t?" (error -505 usually means that the downstream service does not support this http version)":""),p,f))}));const L={hostname:m.hostname,path:R,port:m.port,protocol:m.protocol,rejectUnauthorized:!1,method:n.method,headers:{...Object.assign({},...Object.entries(S).filter((([e])=>!e.startsWith(":")&&"transfer-encoding"!==e.toLowerCase())).map((([e,t])=>({[e]:t})))),host:m.hostname}},B=!w&&!v&&"file:"!==m.protocol&&await new Promise((e=>{const o="https:"===m.protocol?(0,r.request)(L,e):(0,t.request)(L,e);o.on("error",(t=>{w=Buffer.from(b(t,"request",p,f)),e(null)})),n.on("data",(e=>o.write(e))),n.on("end",(()=>o.end()))}));if(w)return void y(502,a,w);w=null,h.ssl&&n.stream&&n.stream.readableLength&&U&&(n.stream.on("data",(e=>U.write(e))),n.stream.on("end",(()=>U.end()))),!h.ssl&&n.readableLength&&U&&(n.on("data",(e=>U.write(e))),n.on("end",(()=>U.end())));const{outboundResponseHeaders:j}=await new Promise((e=>U?U.on("response",(t=>{e({outboundResponseHeaders:t})})):e(!U&&B?{outboundResponseHeaders:B.headers}:{outboundResponseHeaders:{}}))),x=j.location?new o.URL(j.location.startsWith("/")?`${m.href}${j.location.replace(/^\/+/,"")}`:j.location):null,D=x?x.href.substring(x.origin.length):null,T=p.origin,H=x?`${T}${D}`:null,P=U||B,I=w??await new Promise((e=>{let t=Buffer.alloc(0);P?(P.on("data",(e=>t=Buffer.concat([t,"string"==typeof e?Buffer.from(e):e]))),P.on("end",(()=>{e(t)}))):e(t)})).then((e=>h.replaceResponseBodyUrls&&e.length?(j["content-encoding"]||"").split(",").reduce((async(e,t)=>{const r=t.trim().toLowerCase(),o="gzip"===r||"x-gzip"===r?s.gunzip:"deflate"===r?s.inflate:"br"===r?s.brotliDecompress:"identity"===r||""===r?(e,t)=>{t(null,e)}:null;if(null===o)return void y(502,a,Buffer.from(b(new Error(`${r} compression not supported by the proxy`),"stream",p,f)));const n=await e;return await new Promise((e=>o(n,((t,r)=>{if(t)return y(502,a,Buffer.from(b(t,"stream",p,f))),void e(Buffer.from(""));e(r)}))))}),Promise.resolve(e)).then((e=>{const t=e.length>1e7,r=["text/html","application/javascript","application/json"].some((e=>(j["content-type"]??"").includes(e)));return!t&&(r||!/[^\x00-\x7F]/.test(e.toString()))?h.replaceResponseBodyUrls?Object.entries(h.mapping).reduce(((e,[t,r])=>""===t||t.match(/^[-a-zA-Z0-9()@:%_\+.~#?&//=]*$/)?e.replace(new RegExp(r.replace(/^file:\/\//,"").replace(/[*+?^${}()|[\]\\]/g,"").replace(/^https/,"https?")+"/*","ig"),`https://${l}${t.replace(/\/+$/,"")}/`):e),e.toString()).split(`${l}/:`).join(`${l}:`).replace(/\?protocol=wss?%3A&hostname=[^&]+&port=[0-9]+&pathname=/g,`?protocol=ws${h.ssl?"s":""}%3A&hostname=${i}&port=${h.port}&pathname=${encodeURIComponent(u.replace(/\/+$/,""))}`):e.toString():e})).then((e=>(j["content-encoding"]||"").split(",").reduce(((e,t)=>{const r=t.trim().toLowerCase(),o="gzip"===r||"x-gzip"===r?s.gzip:"deflate"===r?s.deflate:"br"===r?s.brotliCompress:"identity"===r||""===r?(e,t)=>{t(null,e)}:null;if(null===o)throw new Error(`${r} compression not supported by the proxy`);return e.then((e=>new Promise((t=>o(e,((e,r)=>{if(e)throw e;t(r)}))))))}),Promise.resolve(Buffer.from(e))))):e)),C={...Object.entries({...j,...h.replaceResponseBodyUrls?{"content-length":`${I.byteLength}`}:{},...h.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,r])=>{const o=g.split("").map(((e,t)=>g.substring(t).startsWith(".")&&g.substring(t))).filter((e=>e)),n=[g].concat(o).reduce(((e,t)=>(Array.isArray(e)?e:[e]).map((e=>"string"==typeof e?e.replace(`Domain=${t}`,`Domain=${p.hostname}`):e))),r);return e[t]=(e[t]||[]).concat(n),e}),{}),...H?{location:[H]}:{}};try{Object.entries(C).forEach((([e,t])=>t&&a.setHeader(e,t)))}catch(e){}a.writeHead(j[":status"]||B.statusCode||200,h.ssl?void 0:B.statusMessage||"Status read from http/2",C),I?a.end(I):a.end()})).addListener("error",(e=>{"EACCES"===e.code&&m("permission denied for this port",i.ERROR,l.NO),"EADDRINUSE"===e.code&&m("port is already used. NOT started",i.ERROR,l.ERROR_6)})).addListener("listening",(()=>{g(h)})).on("upgrade",((e,n)=>{if(!h.websocket)return void n.end("HTTP/1.1 503 Service Unavailable\r\n\r\n");const{key:s,target:a}=E(e),p=new o.URL(`${a.protocol}//${a.host}${e.url.endsWith("/_next/webpack-hmr")?e.url:e.url.replace(new RegExp(`^${s}`,"g"),"").replace(/^\/*/,"/")}`),c={hostname:p.hostname,path:p.pathname,port:p.port,protocol:p.protocol,rejectUnauthorized:!1,method:e.method,headers:e.headers,host:p.hostname},d="https:"===p.protocol?(0,r.request)(c):(0,t.request)(c);d.end(),d.on("error",(e=>{m("websocket request has errored "+(e.errno?`(${e.errno})`:""),i.WARNING,l.WEBSOCKET)})),d.on("upgrade",((e,t)=>{const r=`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`;n.write(r),n.allowHalfOpen=!0,t.allowHalfOpen=!0,t.on("data",(e=>n.write(e))),n.on("data",(e=>t.write(e))),t.on("error",(e=>{m("downstream socket has errored "+(e.errno?`(${e.errno})`:""),i.WARNING,l.WEBSOCKET)})),n.on("error",(e=>{m("upstream socket has errored "+(e.errno?`(${e.errno})`:""),i.WARNING,l.WEBSOCKET)}))}))})).listen(h.port)};R().then(v);
2
+ "use strict";Object.defineProperty(exports,"__esModule",{value:!0});const e=require("http2"),t=require("http"),r=require("https"),n=require("url"),o=require("fs"),s=require("zlib"),a=require("path"),i=require("crypto");var l,c,p;!function(e){e[e.ERROR=124]="ERROR",e[e.INFO=93]="INFO",e[e.WARNING=172]="WARNING"}(l||(l={})),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="☠️ "}(c||(c={})),function(e){e.INBOUND="INBOUND",e.OUTBOUND="OUTBOUND"}(p||(p={}));const d=(0,a.resolve)(process.env.HOME,".local-traffic.json"),u=(0,a.resolve)(process.cwd(),process.argv.slice(-1)[0].endsWith(".json")?process.argv.slice(-1)[0]:d),h={mapping:{},port:8080,replaceRequestBodyUrls:!1,replaceResponseBodyUrls:!1,dontUseHttp2Downstream:!1,simpleLogs:!1,websocket:!1,disableWebSecurity:!1};let m,g,f=[];const y=e=>e===l.ERROR?"error":e===l.WARNING?"warning":"info",R=(e,t,r)=>{const n=m?.simpleLogs||f.length?e.replace(/⎸/g,"|").replace(/⎹/g,"|").replace(/\u001b\[[^m]*m/g,"").replace(new RegExp(c.INBOUND,"g"),"inbound:").replace(new RegExp(c.PORT,"g"),"port:").replace(new RegExp(c.OUTBOUND,"g"),"outbound:").replace(new RegExp(c.RULES,"g"),"rules:").replace(new RegExp(c.NO,"g"),"").replace(new RegExp(c.REWRITE,"g"),"+rewrite").replace(new RegExp(c.WEBSOCKET,"g"),"websocket").replace(new RegExp(c.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?"":""}`})(m?.simpleLogs)} ${m?.simpleLogs?n:t?`[48;5;${t}m⎸ ${process.stdout.isTTY&&r||""} ${e.padEnd(40)} ⎹`:e}`),b({logEvent:n,level:y(t)})},b=e=>{if(!f.length)return;const t=JSON.stringify(e),r=Array(4).fill(0).map((()=>Math.floor(256*Math.random()))),n=[...t.substring(0,65536)].map(((e,t)=>e.charCodeAt(0)^r[3&t])),o=Math.min(65535,t.length),s=t.length<126?Buffer.from(Uint8Array.from([129,128+o]).buffer):Buffer.concat([Buffer.from(Uint8Array.from([129,254]).buffer),Buffer.from(Uint8Array.from([o>>8]).buffer),Buffer.from(Uint8Array.from([255&o]).buffer)]),a=Buffer.from(Int8Array.from(r).buffer),i=Buffer.from(Int8Array.from(n).buffer),l=Buffer.concat([s,a,i]);f.forEach((e=>{try{e.write(l)}catch(e){}}))},w=e=>{R(`⎸${c.PORT} ${e.port.toString().padStart(5)} ⎸${c.INBOUND} ${e.ssl?"H/2 ":"H1.1"}${e.replaceRequestBodyUrls?c.REWRITE:" "}⎹⎸${c.OUTBOUND} ${e.dontUseHttp2Downstream?"H1.1":"H/2 "}${e.replaceResponseBodyUrls?c.REWRITE:" "}⎹⎸${c.RULES}${Object.keys(m.mapping).length.toString().padStart(3)}⎹⎸${m.websocket?c.WEBSOCKET:c.NO}⎹⎸${m.simpleLogs?c.NO:c.COLORED}⎹⎸${m.disableWebSecurity?c.NO:c.SHIELD}⎹`)},O=async(e=!0)=>new Promise((t=>(0,o.readFile)(u,((r,n)=>{r&&!e&&R("config error. Using default value",l.ERROR,c.ERROR_1);try{m=Object.assign({},h,JSON.parse((n||"{}").toString()))}catch(e){return R("config syntax incorrect, aborting",l.ERROR,c.ERROR_2),m=m||{...h},void t(m)}m.mapping[""]||R('default mapping "" not provided.',l.WARNING,c.ERROR_3),r&&"ENOENT"===r.code&&e&&u===d?(0,o.writeFile)(u,JSON.stringify(h),(e=>{e?R("config file NOT created",l.ERROR,c.ERROR_4):R("config file created",l.INFO,c.COLORED),t(m)})):t(m)})))).then((()=>{e&&(0,o.watchFile)(u,$)})),$=async()=>{const e={...m};return await O(!1),isNaN(m.port)||m.port>65535||m.port<0?(m=e,void R("port number invalid. Not refreshing",l.ERROR,c.PORT)):"object"!=typeof m.mapping?(m=e,void R("mapping should be an object. Aborting",l.ERROR,c.ERROR_5)):(m.replaceRequestBodyUrls!==e.replaceRequestBodyUrls&&R(`request body url ${m.replaceRequestBodyUrls?"":"NO "}rewriting`,l.INFO,c.REWRITE),m.replaceResponseBodyUrls!==e.replaceResponseBodyUrls&&R(`response body url ${m.replaceResponseBodyUrls?"":"NO "}rewriting`,l.INFO,c.REWRITE),m.dontUseHttp2Downstream!==e.dontUseHttp2Downstream&&R(`http/2 ${m.dontUseHttp2Downstream?"de":""}activated downstream`,l.INFO,c.OUTBOUND),m.disableWebSecurity!==e.disableWebSecurity&&R(`web security ${m.disableWebSecurity?"de":""}activated`,l.INFO,c.SHIELD),m.websocket!==e.websocket&&R(`websocket ${m.websocket?"":"de"}activated`,l.INFO,c.WEBSOCKET),m.simpleLogs!==e.simpleLogs&&R("simple logs "+(m.simpleLogs?"on":"off"),l.INFO,c.COLORED),Object.keys(m.mapping).join("\n")!==Object.keys(e.mapping).join("\n")&&R(`${Object.keys(m.mapping).length.toString().padStart(5)} loaded mapping rules`,l.INFO,c.RULES),m.port!==e.port&&R(`port changed from ${e.port} to ${m.port}`,l.INFO,c.PORT),m.ssl&&!e.ssl&&R("ssl configuration added",l.INFO,c.INBOUND),!m.ssl&&e.ssl&&R("ssl configuration removed",l.INFO,c.INBOUND),void(m.port!==e.port||JSON.stringify(m.ssl)!==JSON.stringify(e.ssl)?(await new Promise((e=>g?g.close(e):e(void 0))),R("restarting server",l.INFO,c.RESTART),I()):w(m)))},v=e=>""==e?"":(0,a.normalize)(e).replace(/\\/g,"/"),E=e=>{const t=(0,a.resolve)("/",e.hostname,...e.pathname.replace(/[?#].*$/,"").replace(/^\/+/,"").split("/"));return{error:null,data:null,hasRun:!1,run:function(){return this.hasRun?Promise.resolve():new Promise((r=>(0,o.readFile)(t,((n,s)=>{if(this.hasRun=!0,!n||"EISDIR"!==n.code)return this.error=n,this.data=s,void r(void 0);(0,o.readdir)(t,((t,n)=>{this.error=t,this.data=n,t?r(void 0):Promise.all(n.map((t=>new Promise((r=>(0,o.lstat)((0,a.resolve)(e.pathname,t),((e,n)=>r([t,n,e])))))))).then((t=>{const n=t.filter((e=>!e[2]&&e[1].isDirectory())).concat(t.filter((e=>!e[2]&&e[1].isFile())));this.data=`${S(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>${n.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>`,r(void 0)}))}))}))))},events:{},on:function(e,r){return this.events[e]=r,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}}},N=e=>({error:null,data:null,run:function(){return new Promise((t=>{this.data=`${S(128250,"logs","")}<p>Logs page, limited to <select id="limit" onchange="javascript:cleanup()">\n <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></p>\n <table id="table" class="table table-striped" style="display: block; 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="logs">\n </tbody>\n </table>\n <script type="text/javascript">\n function start() {\n document.getElementById('table').style.height =\n (document.documentElement.clientHeight - 150) + 'px';\n const socket = new WebSocket("ws${m.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 eventText = typeof data === 'object' ? '<pre>' + JSON.stringify(data, null, 3)\n .replace(/&/g, '&amp;').replace(/\\\\"/g, '&quot;')\n .replace(/</g, '&lt;').replace(/>/g, '&gt;')\n .replace(/^( *)("[\\w]+": )?("[^"]*"|[\\w.+-]*)?([,[{])?$/mg, (match, pIndent, pKey, pVal, pEnd) => {\n const key = '<span class="json-key">';\n const val = '<span class="json-value">';\n const str = '<span class="json-string">';\n let r = pIndent || '';\n if (pKey)\n r = r + key + pKey.replace(/[": ]/g, '') + '</span>: ';\n if (pVal)\n r = r + (pVal[0] == '"' ? str : val) + pVal + '</span>';\n return r + (pEnd || '');\n }) + '</pre>'\n : data;\n const button = uniqueHash ? '<button data-uniquehash="'+uniqueHash+'" onclick="javascript:replay(event)" ' +\n 'type="button" class="btn btn-primary">Replay</button>' : '';\n document.getElementById("logs")\n .insertAdjacentHTML('beforeend', '<tr><td scope="col">' + new Date().toUTCString() + '</td>' +\n '<td scope="col">' + (data.level || 'info')+ '</td>' + \n '<td scope="col">' + eventText + button + '</td></tr>');\n cleanup();\n };\n socket.onerror = function(error) {\n console.log(\`[error] \${error}\`);\n setTimeout(start, 5000);\n };\n };\n function cleanup() {\n const currentLimit = parseInt(document.getElementById('limit').value)\n while (currentLimit && document.getElementById('logs').childNodes.length && \n document.getElementById('logs').childNodes.length > currentLimit) {\n document.getElementById('logs').childNodes[0].remove();\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 <style type="text/css">\n pre {\n background-color: ghostwhite;\n border: 1px solid silver;\n padding: 10px 20px;\n margin: 20px; \n }\n .json-key {\n color: brown;\n }\n .json-value {\n color: navy;\n }\n .json-string {\n color: olive;\n }\n </style>\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}}),S=(e,t,r)=>`<!doctype html>\n<html lang="en">\n<head>\n<title>&#x${e.toString(16)}; local-traffic ${t} | ${r}</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/>`,B=(e,t,r,n)=>`${S(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>${r}</td>\n </tr>\n <tr>\n <td>downstream URL</td>\n <td>${n||"&lt;no-target-url&gt;"}</td>\n </tr>\n </tbody>\n</table>\n</div></body></html>`,U=async(e,t,r)=>(t["content-encoding"]?.toString()??"").split(",").reduce((async(e,t)=>{const r=t.trim().toLowerCase(),n="gzip"===r||"x-gzip"===r?s.gunzip:"deflate"===r?s.inflate:"br"===r?s.brotliDecompress:"identity"===r||""===r?(e,t)=>{t(null,e)}:null;if(null===n)throw new Error(`${r} compression not supported by the proxy`);const o=await e;return await new Promise(((e,t)=>n(o,((r,n)=>{r&&t(r),e(n)}))))}),Promise.resolve(e)).then((e=>{const n=e.length>1e7,o=["text/html","application/javascript","application/json"].some((e=>(t["content-type"]??"").toString().includes(e)));return!n&&(o||!/[^\x00-\x7F]/.test(e.toString()))?m.replaceResponseBodyUrls?Object.entries(m.mapping).reduce(((e,[t,n])=>n.startsWith("logs:")||""!==t&&!t.match(/^[-a-zA-Z0-9()@:%_\+.~#?&//=]*$/)?e:r.direction===p.INBOUND?e.replace(new RegExp(n.replace(/^(file|logs):\/\//,"").replace(/[*+?^${}()|[\]\\]/g,"").replace(/^https/,"https?")+"/*","ig"),`http${m.ssl?"s":""}://${r.proxyHostnameAndPort}${t.replace(/\/+$/,"")}/`):e.split(`http${m.ssl?"s":""}://${r.proxyHostnameAndPort}${t.replace(/\/+$/,"")}`).join(n)),e.toString()).split(`${r.proxyHostnameAndPort}/:`).join(`${r.proxyHostnameAndPort}:`).replace(/\?protocol=wss?%3A&hostname=[^&]+&port=[0-9]+&pathname=/g,`?protocol=ws${m.ssl?"s":""}%3A&hostname=${r.proxyHostname}&port=${m.port}&pathname=${encodeURIComponent(r.key.replace(/\/+$/,""))}`):e.toString():e})).then((e=>(t["content-encoding"]?.toString()??"").split(",").reduce(((e,t)=>{const r=t.trim().toLowerCase(),n="gzip"===r||"x-gzip"===r?s.gzip:"deflate"===r?s.deflate:"br"===r?s.brotliCompress:"identity"===r||""===r?(e,t)=>{t(null,e)}:null;if(null===n)throw new Error(`${r} compression not supported by the proxy`);return e.then((e=>new Promise((t=>n(e,((e,r)=>{if(e)throw e;t(r)}))))))}),Promise.resolve(Buffer.from(e))))),x=(e,t,r)=>{t.writeHead(e,void 0,{"content-type":"text/html","content-length":r.length}),t.end(r)},H=e=>{const t=(e.headers[":authority"]?.toString()??e.headers.host??"localhost").replace(/:.*/,""),r=e.headers[":authority"]||`${e.headers.host}${e.headers.host.match(/:[0-9]+$/)?"":80!==m.port||m.ssl?443===m.port&&m.ssl?"":`:${m.port}`:""}`,o=new n.URL(`http${m.ssl?"s":""}://${r}${e.url}`),s=o.href.substring(o.origin.length),[a,i]=Object.entries({...Object.assign({},...Object.entries(m.mapping).map((([e,t])=>({[e]:new n.URL(v(t))}))))}).find((([e])=>s.match(RegExp(e.replace(/^\//,"^/")))))||[];return{proxyHostname:t,proxyHostnameAndPort:r,url:o,path:s,key:a,target:i}},I=()=>{g=(m.ssl?e.createSecureServer.bind(null,{...m.ssl,allowHTTP1:!0}):t.createServer)((async(o,s)=>{if(!o.headers.host&&!o.headers[":authority"])return void x(400,s,Buffer.from(B(new Error("client must supply a 'host' header"),"proxy",new n.URL(`http${m.ssl?"s":""}://unknowndomain${o.url}`))));const{proxyHostname:a,proxyHostnameAndPort:i,url:l,path:c,key:d,target:h}=H(o);if(!h)return void x(502,s,Buffer.from(B(new Error(`No mapping found in config file ${u}`),"proxy",l)));const g=h.host.replace(RegExp(/\/+$/),""),y=`${h.href.substring("https://".length+h.host.length)}${v(c.replace(RegExp(v(d)),""))}`.replace(/^\/*/,"/"),R=new n.URL(`${h.protocol}//${g}${y}`);let w=null,O=!m.dontUseHttp2Downstream;const $="file:"===h.protocol?E(R):"logs:"===h.protocol?N(i):O?await Promise.race([new Promise((t=>{const r=(0,e.connect)(R,{rejectUnauthorized:!1,protocol:h.protocol},((e,n)=>{O=O&&!!n.alpnProtocol,t(O?r:null)}));r.on("error",(e=>{w=O&&Buffer.from(B(e,"connection",l,R))}))})),new Promise((e=>setTimeout((()=>{O=!1,e(null)}),3e3)))]):null;w instanceof Buffer||(w=null);const S=o?.readableLength,I=o?.stream?.readableLength;let T=null;const j=m.replaceRequestBodyUrls||f.length;if(j){const e=o?.stream??o;let t=Buffer.from([]);await new Promise((r=>{0===I&&r(void 0),e.on("data",(e=>{t=Buffer.concat([t,e])})),e.on("end",r),e.on("error",r)})),T=await U(t,o.headers,{proxyHostnameAndPort:i,proxyHostname:a,key:d,direction:p.OUTBOUND})}const D={...[...Object.entries(o.headers)].filter((([e])=>!["host","connection","keep-alive"].includes(e.toLowerCase()))).reduce(((e,[t,r])=>(e[t]=(e[t]||"")+(Array.isArray(r)?r:[r]).map((e=>e.replace(l.hostname,g))).join(", "),e)),{}),origin:h.href,referer:R.toString(),"content-length":T?.length??o.headers["content-length"]??0,":authority":g,":method":o.method,":path":y,":scheme":h.protocol.replace(":","")},L=$&&!w&&$.request(D,{endStream:m.ssl?!(I??1):!S});L?.on("error",(e=>{const t=-505===e.errno;w=Buffer.from(B(e,"stream"+(t?" (error -505 usually means that the downstream service does not support this http version)":""),l,R))}));const A={hostname:h.hostname,path:y,port:h.port,protocol:h.protocol,rejectUnauthorized:!1,method:o.method,headers:{...Object.assign({},...Object.entries(D).filter((([e])=>!e.startsWith(":")&&"transfer-encoding"!==e.toLowerCase())).map((([e,t])=>({[e]:t})))),host:h.hostname}},k=!w&&!O&&!["file:","logs:"].includes(h.protocol)&&await new Promise((e=>{const n="https:"===h.protocol?(0,r.request)(A,e):(0,t.request)(A,e);n.on("error",(t=>{w=Buffer.from(B(t,"request",l,R)),e(null)})),j&&(n.write(T),n.end()),j||(o.on("data",(e=>n.write(e))),o.on("end",(()=>n.end())))}));if(w)return void x(502,s,w);w=null,m.ssl&&I&&L&&(j&&(L.write(T),L.end()),j||(o.stream.on("data",(e=>{L.write(e)})),o.stream.on("end",(()=>L.end())))),!m.ssl&&S&&L&&(j&&(L.write(T),L.end()),j||(o.on("data",(e=>{L.write(e)})),o.on("end",(()=>L.end()))));const{outboundResponseHeaders:q}=await new Promise((e=>L?L.on("response",(t=>{e({outboundResponseHeaders:t})})):e(!L&&k?{outboundResponseHeaders:k.headers}:{outboundResponseHeaders:{}})));b({level:"info",protocol:O?"HTTP/2":"HTTP1.1",method:o.method,path:y,uniqueHash:Buffer.from(JSON.stringify({method:o.method,url:o.url,headers:Object.fromEntries(Object.entries(o.headers).filter((([e])=>!e.startsWith(":")))),body:T?.toJSON()})).toString("base64")});const P=q.location?new n.URL(q.location.startsWith("/")?`${h.href}${q.location.replace(/^\/+/,"")}`:q.location):null,W=P?P.href.substring(P.origin.length):null,C=l.origin,F=P?`${C}${W}`:null,_=L||k,M=w??await new Promise((e=>{let t=Buffer.alloc(0);_?(_.on("data",(e=>t=Buffer.concat([t,"string"==typeof e?Buffer.from(e):e]))),_.on("end",(()=>{e(t)}))):e(t)})).then((e=>m.replaceResponseBodyUrls&&e.length?U(e,q,{proxyHostnameAndPort:i,proxyHostname:a,key:d,direction:p.INBOUND}).catch((e=>(x(502,s,Buffer.from(B(e,"stream",l,R))),Buffer.from("")))):e)),z={...Object.entries({...q,...m.replaceResponseBodyUrls?{"content-length":`${M.byteLength}`}:{},...m.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,r])=>{const n=g.split("").map(((e,t)=>g.substring(t).startsWith(".")&&g.substring(t))).filter((e=>e)),o=[g].concat(n).reduce(((e,t)=>(Array.isArray(e)?e:[e]).map((e=>"string"==typeof e?e.replace(`Domain=${t}`,`Domain=${l.hostname}`):e))),r);return e[t]=(e[t]||[]).concat(o),e}),{}),...F?{location:[F]}:{}};try{Object.entries(z).forEach((([e,t])=>t&&s.setHeader(e,t)))}catch(e){}s.writeHead(q[":status"]||k.statusCode||200,m.ssl?void 0:k.statusMessage||"Status read from http/2",z),M?s.end(M):s.end()})).addListener("error",(e=>{"EACCES"===e.code&&R("permission denied for this port",l.ERROR,c.NO),"EADDRINUSE"===e.code&&R("port is already used. NOT started",l.ERROR,c.ERROR_6)})).addListener("listening",(()=>{w(m)})).on("upgrade",((e,o)=>{if(!m.websocket)return void o.end("HTTP/1.1 503 Service Unavailable\r\n\r\n");const{key:s,target:a,path:p}=H(e);if("/local-traffic-logs"===p){const t=(0,i.createHash)("sha1");t.update(e.headers["sec-websocket-key"]+"258EAFA5-E914-47DA-95CA-C5AB0DC85B11");const r=t.digest("base64");return o.allowHalfOpen=!0,o.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: ${r}\r\n\r\n`),o.on("close",(()=>{f=f.filter((e=>o!==e))})),void f.push(o)}const d=new n.URL(`${a.protocol}//${a.host}${e.url.endsWith("/_next/webpack-hmr")?e.url:e.url.replace(new RegExp(`^${s}`,"g"),"").replace(/^\/*/,"/")}`),u={hostname:d.hostname,path:d.pathname,port:d.port,protocol:d.protocol,rejectUnauthorized:!1,method:e.method,headers:e.headers,host:d.hostname},h="https:"===d.protocol?(0,r.request)(u):(0,t.request)(u);h.end(),h.on("error",(e=>{R("websocket request has errored "+(e.errno?`(${e.errno})`:""),l.WARNING,c.WEBSOCKET)})),h.on("upgrade",((e,t)=>{const r=`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`;o.write(r),o.allowHalfOpen=!0,t.allowHalfOpen=!0,t.on("data",(e=>o.write(e))),o.on("data",(e=>t.write(e))),t.on("error",(e=>{R("downstream socket has errored "+(e.errno?`(${e.errno})`:""),l.WARNING,c.WEBSOCKET)})),o.on("error",(e=>{R("upstream socket has errored "+(e.errno?`(${e.errno})`:""),l.WARNING,c.WEBSOCKET)}))}))})).listen(m.port)};O().then(I);
package/index.ts CHANGED
@@ -32,7 +32,8 @@ import {
32
32
  brotliDecompress,
33
33
  } from "zlib";
34
34
  import { resolve, normalize } from "path";
35
- import type { Duplex } from "stream";
35
+ import { createHash } from "crypto";
36
+ import type { Duplex, Readable } from "stream";
36
37
 
37
38
  type ErrorWithErrno = NodeJS.ErrnoException;
38
39
 
@@ -47,7 +48,8 @@ enum EMOJIS {
47
48
  PORT = "☎️ ",
48
49
  OUTBOUND = "↗️ ",
49
50
  RULES = "🔗",
50
- BODY_REPLACEMENT = "✒️ ",
51
+ REWRITE = "✒️ ",
52
+ RESTART = "🔄",
51
53
  WEBSOCKET = "☄️ ",
52
54
  COLORED = "✨",
53
55
  SHIELD = "🛡️ ",
@@ -60,10 +62,16 @@ enum EMOJIS {
60
62
  ERROR_6 = "☠️ ",
61
63
  }
62
64
 
65
+ enum REPLACEMENT_DIRECTION {
66
+ INBOUND = "INBOUND",
67
+ OUTBOUND = "OUTBOUND",
68
+ }
69
+
63
70
  interface LocalConfiguration {
64
71
  mapping?: { [subPath: string]: string };
65
72
  ssl?: SecureServerOptions;
66
73
  port?: number;
74
+ replaceRequestBodyUrls?: boolean;
67
75
  replaceResponseBodyUrls?: boolean;
68
76
  dontUseHttp2Downstream?: boolean;
69
77
  simpleLogs?: boolean;
@@ -81,6 +89,7 @@ const filename = resolve(
81
89
  const defaultConfig: LocalConfiguration = {
82
90
  mapping: {},
83
91
  port: 8080,
92
+ replaceRequestBodyUrls: false,
84
93
  replaceResponseBodyUrls: false,
85
94
  dontUseHttp2Downstream: false,
86
95
  simpleLogs: false,
@@ -90,6 +99,7 @@ const defaultConfig: LocalConfiguration = {
90
99
 
91
100
  let config: LocalConfiguration;
92
101
  let server: Server;
102
+ let logsListeners: Duplex[] = [];
93
103
  const getCurrentTime = (simpleLogs?: boolean) => {
94
104
  const date = new Date();
95
105
  return `${simpleLogs ? "" : "\u001b[36m"}${`${date.getHours()}`.padStart(
@@ -101,26 +111,34 @@ const getCurrentTime = (simpleLogs?: boolean) => {
101
111
  simpleLogs ? ":" : "\u001b[33m:\u001b[36m"
102
112
  }${`${date.getSeconds()}`.padStart(2, "0")}${simpleLogs ? "" : "\u001b[0m"}`;
103
113
  };
114
+ const levelToString = (level: LogLevel) =>
115
+ level === LogLevel.ERROR
116
+ ? "error"
117
+ : level === LogLevel.WARNING
118
+ ? "warning"
119
+ : "info";
104
120
  const log = (text: string, level?: LogLevel, emoji?: string) => {
121
+ const simpleLog =
122
+ config?.simpleLogs || logsListeners.length
123
+ ? text
124
+ .replace(/⎸/g, "|")
125
+ .replace(/⎹/g, "|")
126
+ .replace(/\u001b\[[^m]*m/g, "")
127
+ .replace(new RegExp(EMOJIS.INBOUND, "g"), "inbound:")
128
+ .replace(new RegExp(EMOJIS.PORT, "g"), "port:")
129
+ .replace(new RegExp(EMOJIS.OUTBOUND, "g"), "outbound:")
130
+ .replace(new RegExp(EMOJIS.RULES, "g"), "rules:")
131
+ .replace(new RegExp(EMOJIS.NO, "g"), "")
132
+ .replace(new RegExp(EMOJIS.REWRITE, "g"), "+rewrite")
133
+ .replace(new RegExp(EMOJIS.WEBSOCKET, "g"), "websocket")
134
+ .replace(new RegExp(EMOJIS.SHIELD, "g"), "web-security")
135
+ .replace(/\|+/g, "|")
136
+ : text;
137
+
105
138
  console.log(
106
139
  `${getCurrentTime(config?.simpleLogs)} ${
107
140
  config?.simpleLogs
108
- ? text
109
- .replace(/⎸/g, "|")
110
- .replace(/⎹/g, "|")
111
- .replace(/\u001b\[[^m]*m/g, "")
112
- .replace(new RegExp(EMOJIS.INBOUND, "g"), "inbound:")
113
- .replace(new RegExp(EMOJIS.PORT, "g"), "port:")
114
- .replace(new RegExp(EMOJIS.OUTBOUND, "g"), "outbound:")
115
- .replace(new RegExp(EMOJIS.RULES, "g"), "rules:")
116
- .replace(new RegExp(EMOJIS.NO, "g"), "")
117
- .replace(
118
- new RegExp(EMOJIS.BODY_REPLACEMENT, "g"),
119
- "body replacement",
120
- )
121
- .replace(new RegExp(EMOJIS.WEBSOCKET, "g"), "websocket")
122
- .replace(new RegExp(EMOJIS.SHIELD, "g"), "web-security")
123
- .replace(/\|+/g, "|")
141
+ ? simpleLog
124
142
  : level
125
143
  ? `\u001b[48;5;${level}m⎸ ${
126
144
  !process.stdout.isTTY ? "" : emoji || ""
@@ -128,6 +146,40 @@ const log = (text: string, level?: LogLevel, emoji?: string) => {
128
146
  : text
129
147
  }`,
130
148
  );
149
+ notifyLogsListener({
150
+ logEvent: simpleLog,
151
+ level: levelToString(level),
152
+ });
153
+ };
154
+
155
+ const notifyLogsListener = (data: Record<string, unknown>) => {
156
+ if (!logsListeners.length) return;
157
+ const text = JSON.stringify(data);
158
+ const mask = Array(4)
159
+ .fill(0)
160
+ .map(() => Math.floor(Math.random() * (2 << 7)));
161
+ const maskedTextBits = [...text.substring(0, 2 << 15)].map(
162
+ (c, i) => c.charCodeAt(0) ^ mask[i & 3],
163
+ );
164
+ const length = Math.min((2 << 15) - 1, text.length);
165
+ const header =
166
+ text.length < (2 << 6) - 2
167
+ ? Buffer.from(Uint8Array.from([(1 << 7) + 1, (1 << 7) + length]).buffer)
168
+ : Buffer.concat([
169
+ Buffer.from(Uint8Array.from([(1 << 7) + 1, (2 << 7) - 2]).buffer),
170
+ Buffer.from(Uint8Array.from([length >> 8]).buffer),
171
+ Buffer.from(Uint8Array.from([length & ((2 << 7) - 1)]).buffer),
172
+ ]);
173
+ const maskingKey = Buffer.from(Int8Array.from(mask).buffer);
174
+ const payload = Buffer.from(Int8Array.from(maskedTextBits).buffer);
175
+ const value = Buffer.concat([header, maskingKey, payload]);
176
+ logsListeners.forEach(logsListener => {
177
+ try {
178
+ logsListener.write(value);
179
+ } catch (e) {
180
+ // ignore logs that trigger errors in the pipe
181
+ }
182
+ });
131
183
  };
132
184
 
133
185
  const quickStatus = (thisConfig: LocalConfiguration) => {
@@ -136,17 +188,19 @@ const quickStatus = (thisConfig: LocalConfiguration) => {
136
188
  .toString()
137
189
  .padStart(5)} \u001b[48;5;53m⎸${EMOJIS.INBOUND} ${
138
190
  thisConfig.ssl ? "H/2 " : "H1.1"
139
- } \u001b[48;5;54m⎸${EMOJIS.OUTBOUND} ${
191
+ }${
192
+ thisConfig.replaceRequestBodyUrls ? EMOJIS.REWRITE : " "
193
+ }⎹\u001b[48;5;54m⎸${EMOJIS.OUTBOUND} ${
140
194
  thisConfig.dontUseHttp2Downstream ? "H1.1" : "H/2 "
195
+ }${
196
+ thisConfig.replaceResponseBodyUrls ? EMOJIS.REWRITE : " "
141
197
  }⎹\u001b[48;5;55m⎸${EMOJIS.RULES}${Object.keys(config.mapping)
142
198
  .length.toString()
143
199
  .padStart(3)}⎹\u001b[48;5;56m⎸${
144
- config.replaceResponseBodyUrls ? EMOJIS.BODY_REPLACEMENT : EMOJIS.NO
145
- }⎹\u001b[48;5;57m⎸${
146
200
  config.websocket ? EMOJIS.WEBSOCKET : EMOJIS.NO
147
- }⎹\u001b[48;5;93m⎸${
201
+ }⎹\u001b[48;5;57m⎸${
148
202
  !config.simpleLogs ? EMOJIS.COLORED : EMOJIS.NO
149
- }⎹\u001b[48;5;98m⎸${
203
+ }⎹\u001b[48;5;93m⎸${
150
204
  config.disableWebSecurity ? EMOJIS.NO : EMOJIS.SHIELD
151
205
  }⎹\u001b[0m`,
152
206
  );
@@ -220,15 +274,24 @@ const onWatch = async () => {
220
274
  );
221
275
  return;
222
276
  }
277
+ if (config.replaceRequestBodyUrls !== previousConfig.replaceRequestBodyUrls) {
278
+ log(
279
+ `request body url ${
280
+ !config.replaceRequestBodyUrls ? "NO " : ""
281
+ }rewriting`,
282
+ LogLevel.INFO,
283
+ EMOJIS.REWRITE,
284
+ );
285
+ }
223
286
  if (
224
287
  config.replaceResponseBodyUrls !== previousConfig.replaceResponseBodyUrls
225
288
  ) {
226
289
  log(
227
290
  `response body url ${
228
291
  !config.replaceResponseBodyUrls ? "NO " : ""
229
- }replacement`,
292
+ }rewriting`,
230
293
  LogLevel.INFO,
231
- EMOJIS.BODY_REPLACEMENT,
294
+ EMOJIS.REWRITE,
232
295
  );
233
296
  }
234
297
  if (config.dontUseHttp2Downstream !== previousConfig.dontUseHttp2Downstream) {
@@ -291,6 +354,7 @@ const onWatch = async () => {
291
354
  await new Promise(resolve =>
292
355
  !server ? resolve(void 0) : server.close(resolve),
293
356
  );
357
+ log(`restarting server`, LogLevel.INFO, EMOJIS.RESTART);
294
358
  start();
295
359
  } else quickStatus(config);
296
360
  };
@@ -416,6 +480,146 @@ const fileRequest = (url: URL): ClientHttp2Session => {
416
480
  } as unknown as ClientHttp2Session;
417
481
  };
418
482
 
483
+ const logsPage = (proxyHostnameAndPort: string): ClientHttp2Session =>
484
+ ({
485
+ error: null as Error,
486
+ data: null as string | Buffer,
487
+ run: function () {
488
+ return new Promise(resolve => {
489
+ this.data = `${header(
490
+ 0x1f4fa,
491
+ "logs",
492
+ "",
493
+ )}<p>Logs page, limited to <select id="limit" onchange="javascript:cleanup()">
494
+ <option value="-1">0 (clear)</option><option value="10">10</option>
495
+ <option value="50">50</option><option value="100">100</option><option value="200">200</option>
496
+ <option selected="selected" value="500">500</option><option value="0">Infinity (discouraged)</option>
497
+ </select></p>
498
+ <table id="table" class="table table-striped" style="display: block; width: 100%; overflow-y: auto">
499
+ <thead>
500
+ <tr>
501
+ <th scope="col">Date</th>
502
+ <th scope="col">Level</th>
503
+ <th scope="col">Message</th>
504
+ </tr>
505
+ </thead>
506
+ <tbody id="logs">
507
+ </tbody>
508
+ </table>
509
+ <script type="text/javascript">
510
+ function start() {
511
+ document.getElementById('table').style.height =
512
+ (document.documentElement.clientHeight - 150) + 'px';
513
+ const socket = new WebSocket("ws${
514
+ config.ssl ? "s" : ""
515
+ }://${proxyHostnameAndPort}/local-traffic-logs");
516
+ socket.onmessage = function(event) {
517
+ let data = event.data
518
+ let uniqueHash;
519
+ try {
520
+ const { uniqueHash: uniqueHash1, ...data1 } = JSON.parse(event.data);
521
+ data = data1;
522
+ uniqueHash = uniqueHash1;
523
+ } catch(e) { }
524
+ const eventText = typeof data === 'object' ? '<pre>' + JSON.stringify(data, null, 3)
525
+ .replace(/&/g, '&amp;').replace(/\\\\"/g, '&quot;')
526
+ .replace(/</g, '&lt;').replace(/>/g, '&gt;')
527
+ .replace(/^( *)("[\\w]+": )?("[^"]*"|[\\w.+-]*)?([,[{])?$/mg, (match, pIndent, pKey, pVal, pEnd) => {
528
+ const key = '<span class="json-key">';
529
+ const val = '<span class="json-value">';
530
+ const str = '<span class="json-string">';
531
+ let r = pIndent || '';
532
+ if (pKey)
533
+ r = r + key + pKey.replace(/[": ]/g, '') + '</span>: ';
534
+ if (pVal)
535
+ r = r + (pVal[0] == '"' ? str : val) + pVal + '</span>';
536
+ return r + (pEnd || '');
537
+ }) + '</pre>'
538
+ : data;
539
+ const button = uniqueHash ? '<button data-uniquehash="'+uniqueHash+'" onclick="javascript:replay(event)" ' +
540
+ 'type="button" class="btn btn-primary">Replay</button>' : '';
541
+ document.getElementById("logs")
542
+ .insertAdjacentHTML('beforeend', '<tr><td scope="col">' + new Date().toUTCString() + '</td>' +
543
+ '<td scope="col">' + (data.level || 'info')+ '</td>' +
544
+ '<td scope="col">' + eventText + button + '</td></tr>');
545
+ cleanup();
546
+ };
547
+ socket.onerror = function(error) {
548
+ console.log(\`[error] \${error}\`);
549
+ setTimeout(start, 5000);
550
+ };
551
+ };
552
+ function cleanup() {
553
+ const currentLimit = parseInt(document.getElementById('limit').value)
554
+ while (currentLimit && document.getElementById('logs').childNodes.length &&
555
+ document.getElementById('logs').childNodes.length > currentLimit) {
556
+ document.getElementById('logs').childNodes[0].remove();
557
+ }
558
+ }
559
+ function replay(event) {
560
+ const uniqueHash = event.target.dataset.uniquehash;
561
+ const { method, url, headers, body } = JSON.parse(atob(uniqueHash));
562
+ fetch(url, {
563
+ method,
564
+ headers,
565
+ body: !body.data || !body.data.length
566
+ ? undefined
567
+ : new TextDecoder().decode(new Int8Array(body.data))
568
+ });
569
+ }
570
+ window.addEventListener("DOMContentLoaded", start);
571
+ </script>
572
+ <style type="text/css">
573
+ pre {
574
+ background-color: ghostwhite;
575
+ border: 1px solid silver;
576
+ padding: 10px 20px;
577
+ margin: 20px;
578
+ }
579
+ .json-key {
580
+ color: brown;
581
+ }
582
+ .json-value {
583
+ color: navy;
584
+ }
585
+ .json-string {
586
+ color: olive;
587
+ }
588
+ </style>
589
+ </body></html>`;
590
+ resolve(void 0);
591
+ });
592
+ },
593
+ events: {} as { [name: string]: (...any: any) => any },
594
+ on: function (name: string, action: (...any: any) => any) {
595
+ this.events[name] = action;
596
+ this.run().then(() => {
597
+ if (name === "response")
598
+ this.events["response"](
599
+ {
600
+ Server: "local",
601
+ "Content-Type": "text/html",
602
+ },
603
+ 0,
604
+ );
605
+ if (name === "data" && this.data) {
606
+ this.events["data"](this.data);
607
+ this.events["end"]();
608
+ }
609
+ if (name === "error" && this.error) {
610
+ this.events["error"](this.error);
611
+ }
612
+ });
613
+ return this;
614
+ },
615
+ end: function () {
616
+ return this;
617
+ },
618
+ request: function () {
619
+ return this;
620
+ },
621
+ } as unknown as ClientHttp2Session);
622
+
419
623
  const header = (
420
624
  icon: number,
421
625
  category: string,
@@ -469,6 +673,138 @@ More information about the request :
469
673
  </table>
470
674
  </div></body></html>`;
471
675
 
676
+ const replaceBody = async (
677
+ payloadBuffer: Buffer,
678
+ headers: Record<string, number | string | string[]>,
679
+ parameters: {
680
+ proxyHostnameAndPort: string;
681
+ proxyHostname: string;
682
+ key: string;
683
+ direction: REPLACEMENT_DIRECTION;
684
+ },
685
+ ) =>
686
+ (headers["content-encoding"]?.toString() ?? "")
687
+ .split(",")
688
+ .reduce(async (buffer: Promise<Buffer>, formatNotTrimed: string) => {
689
+ const format = formatNotTrimed.trim().toLowerCase();
690
+ const method =
691
+ format === "gzip" || format === "x-gzip"
692
+ ? gunzip
693
+ : format === "deflate"
694
+ ? inflate
695
+ : format === "br"
696
+ ? brotliDecompress
697
+ : format === "identity" || format === ""
698
+ ? (input: Buffer, callback: (err?: Error, data?: Buffer) => void) => {
699
+ callback(null, input);
700
+ }
701
+ : null;
702
+ if (method === null) {
703
+ throw new Error(`${format} compression not supported by the proxy`);
704
+ }
705
+
706
+ const openedBuffer = await buffer;
707
+ return await new Promise<Buffer>((resolve, reject) =>
708
+ method(openedBuffer, (err_1, data_1) => {
709
+ if (err_1) {
710
+ reject(err_1);
711
+ }
712
+ resolve(data_1);
713
+ }),
714
+ );
715
+ }, Promise.resolve(payloadBuffer))
716
+ .then((uncompressedBuffer: Buffer) => {
717
+ const fileTooBig = uncompressedBuffer.length > 1e7;
718
+ const fileHasSpecialChars = () =>
719
+ /[^\x00-\x7F]/.test(uncompressedBuffer.toString());
720
+ const contentTypeCanBeProcessed = [
721
+ "text/html",
722
+ "application/javascript",
723
+ "application/json",
724
+ ].some(allowedContentType =>
725
+ (headers["content-type"] ?? "").toString().includes(allowedContentType),
726
+ );
727
+ const willReplace =
728
+ !fileTooBig && (contentTypeCanBeProcessed || !fileHasSpecialChars());
729
+
730
+ return !willReplace
731
+ ? uncompressedBuffer
732
+ : !config.replaceResponseBodyUrls
733
+ ? uncompressedBuffer.toString()
734
+ : Object.entries(config.mapping)
735
+ .reduce(
736
+ (inProgress, [path, mapping]) =>
737
+ mapping.startsWith("logs:") ||
738
+ (path !== "" && !path.match(/^[-a-zA-Z0-9()@:%_\+.~#?&//=]*$/))
739
+ ? inProgress
740
+ : parameters.direction === REPLACEMENT_DIRECTION.INBOUND
741
+ ? inProgress.replace(
742
+ new RegExp(
743
+ mapping
744
+ .replace(/^(file|logs):\/\//, "")
745
+ .replace(/[*+?^${}()|[\]\\]/g, "")
746
+ .replace(/^https/, "https?") + "/*",
747
+ "ig",
748
+ ),
749
+ `http${config.ssl ? "s" : ""}://${
750
+ parameters.proxyHostnameAndPort
751
+ }${path.replace(/\/+$/, "")}/`,
752
+ )
753
+ : inProgress
754
+ .split(
755
+ `http${config.ssl ? "s" : ""}://${
756
+ parameters.proxyHostnameAndPort
757
+ }${path.replace(/\/+$/, "")}`,
758
+ )
759
+ .join(mapping),
760
+ uncompressedBuffer.toString(),
761
+ )
762
+ .split(`${parameters.proxyHostnameAndPort}/:`)
763
+ .join(`${parameters.proxyHostnameAndPort}:`)
764
+ .replace(
765
+ /\?protocol=wss?%3A&hostname=[^&]+&port=[0-9]+&pathname=/g,
766
+ `?protocol=ws${config.ssl ? "s" : ""}%3A&hostname=${
767
+ parameters.proxyHostname
768
+ }&port=${config.port}&pathname=${encodeURIComponent(
769
+ parameters.key.replace(/\/+$/, ""),
770
+ )}`,
771
+ );
772
+ })
773
+ .then((updatedBody: Buffer | string) =>
774
+ (headers["content-encoding"]?.toString() ?? "")
775
+ .split(",")
776
+ .reduce((buffer: Promise<Buffer>, formatNotTrimed: string) => {
777
+ const format = formatNotTrimed.trim().toLowerCase();
778
+ const method =
779
+ format === "gzip" || format === "x-gzip"
780
+ ? gzip
781
+ : format === "deflate"
782
+ ? deflate
783
+ : format === "br"
784
+ ? brotliCompress
785
+ : format === "identity" || format === ""
786
+ ? (
787
+ input: Buffer,
788
+ callback: (err?: Error, data?: Buffer) => void,
789
+ ) => {
790
+ callback(null, input);
791
+ }
792
+ : null;
793
+ if (method === null)
794
+ throw new Error(`${format} compression not supported by the proxy`);
795
+
796
+ return buffer.then(
797
+ data =>
798
+ new Promise<Buffer>(resolve =>
799
+ method(data, (err, data) => {
800
+ if (err) throw err;
801
+ resolve(data);
802
+ }),
803
+ ),
804
+ );
805
+ }, Promise.resolve(Buffer.from(updatedBody))),
806
+ );
807
+
472
808
  const send = (
473
809
  code: number,
474
810
  inboundResponse: Http2ServerResponse | ServerResponse,
@@ -588,6 +924,8 @@ const start = () => {
588
924
  const outboundRequest: ClientHttp2Session =
589
925
  target.protocol === "file:"
590
926
  ? fileRequest(targetUrl)
927
+ : target.protocol === "logs:"
928
+ ? logsPage(proxyHostnameAndPort)
591
929
  : !http2IsSupported
592
930
  ? null
593
931
  : await Promise.race([
@@ -624,6 +962,43 @@ const start = () => {
624
962
  ]);
625
963
  if (!(error instanceof Buffer)) error = null;
626
964
 
965
+ const http1WithRequestBody = (inboundRequest as IncomingMessage)
966
+ ?.readableLength;
967
+ const http2WithRequestBody = (inboundRequest as Http2ServerRequest)
968
+ ?.stream?.readableLength;
969
+
970
+ let requestBody: Buffer | null = null;
971
+ const bufferedRequestBody =
972
+ config.replaceRequestBodyUrls || logsListeners.length;
973
+ if (bufferedRequestBody) {
974
+ // this is optional,
975
+ // I don't want to buffer request bodies if
976
+ // none of the options are activated
977
+ const requestBodyReadable: Readable =
978
+ (inboundRequest as Http2ServerRequest)?.stream ??
979
+ (inboundRequest as IncomingMessage);
980
+
981
+ let requestBodyBuffer: Buffer = Buffer.from([]);
982
+ await new Promise(resolve => {
983
+ if (http2WithRequestBody === 0) resolve(void 0);
984
+ requestBodyReadable.on("data", chunk => {
985
+ requestBodyBuffer = Buffer.concat([requestBodyBuffer, chunk]);
986
+ });
987
+ requestBodyReadable.on("end", resolve);
988
+ requestBodyReadable.on("error", resolve);
989
+ });
990
+ requestBody = await replaceBody(
991
+ requestBodyBuffer,
992
+ inboundRequest.headers,
993
+ {
994
+ proxyHostnameAndPort,
995
+ proxyHostname,
996
+ key,
997
+ direction: REPLACEMENT_DIRECTION.OUTBOUND,
998
+ },
999
+ );
1000
+ }
1001
+
627
1002
  const outboundHeaders: OutgoingHttpHeaders = {
628
1003
  ...[...Object.entries(inboundRequest.headers)]
629
1004
  // host, connection and keep-alive are forbidden in http/2
@@ -643,6 +1018,10 @@ const start = () => {
643
1018
  }, {}),
644
1019
  origin: target.href,
645
1020
  referer: targetUrl.toString(),
1021
+ "content-length":
1022
+ requestBody?.length ??
1023
+ inboundRequest.headers["content-length"] ??
1024
+ 0,
646
1025
  ":authority": targetHost,
647
1026
  ":method": inboundRequest.method,
648
1027
  ":path": fullPath,
@@ -654,11 +1033,8 @@ const start = () => {
654
1033
  !error &&
655
1034
  outboundRequest.request(outboundHeaders, {
656
1035
  endStream: config.ssl
657
- ? !(
658
- (inboundRequest as Http2ServerRequest)?.stream
659
- ?.readableLength ?? true
660
- )
661
- : !(inboundRequest as IncomingMessage).readableLength,
1036
+ ? !(http2WithRequestBody ?? true)
1037
+ : !http1WithRequestBody,
662
1038
  });
663
1039
 
664
1040
  outboundExchange?.on("error", (thrown: Error) => {
@@ -702,7 +1078,7 @@ const start = () => {
702
1078
  const outboundHttp1Response: IncomingMessage =
703
1079
  !error &&
704
1080
  !http2IsSupported &&
705
- target.protocol !== "file:" &&
1081
+ !["file:", "logs:"].includes(target.protocol) &&
706
1082
  (await new Promise(resolve => {
707
1083
  const outboundHttp1Request: ClientRequest =
708
1084
  target.protocol === "https:"
@@ -713,10 +1089,17 @@ const start = () => {
713
1089
  error = Buffer.from(errorPage(thrown, "request", url, targetUrl));
714
1090
  resolve(null as IncomingMessage);
715
1091
  });
716
- inboundRequest.on("data", chunk =>
717
- outboundHttp1Request.write(chunk),
718
- );
719
- inboundRequest.on("end", () => outboundHttp1Request.end());
1092
+ if (bufferedRequestBody) {
1093
+ outboundHttp1Request.write(requestBody);
1094
+ outboundHttp1Request.end();
1095
+ }
1096
+
1097
+ if (!bufferedRequestBody) {
1098
+ inboundRequest.on("data", chunk =>
1099
+ outboundHttp1Request.write(chunk),
1100
+ );
1101
+ inboundRequest.on("end", () => outboundHttp1Request.end());
1102
+ }
720
1103
  }));
721
1104
  // intriguingly, error is reset to "false" at this point, even if it was null
722
1105
  if (error) {
@@ -725,31 +1108,36 @@ const start = () => {
725
1108
  } else error = null;
726
1109
 
727
1110
  // phase : request body
728
- if (
729
- config.ssl && // http/2
730
- (inboundRequest as Http2ServerRequest).stream &&
731
- (inboundRequest as Http2ServerRequest).stream.readableLength &&
732
- outboundExchange
733
- ) {
734
- (inboundRequest as Http2ServerRequest).stream.on("data", chunk =>
735
- outboundExchange.write(chunk),
736
- );
737
- (inboundRequest as Http2ServerRequest).stream.on("end", () =>
738
- outboundExchange.end(),
739
- );
1111
+ if (config.ssl && http2WithRequestBody && outboundExchange) {
1112
+ if (bufferedRequestBody) {
1113
+ outboundExchange.write(requestBody);
1114
+ outboundExchange.end();
1115
+ }
1116
+
1117
+ if (!bufferedRequestBody) {
1118
+ (inboundRequest as Http2ServerRequest).stream.on("data", chunk => {
1119
+ outboundExchange.write(chunk);
1120
+ });
1121
+ (inboundRequest as Http2ServerRequest).stream.on("end", () =>
1122
+ outboundExchange.end(),
1123
+ );
1124
+ }
740
1125
  }
741
1126
 
742
- if (
743
- !config.ssl && // http1.1
744
- (inboundRequest as IncomingMessage).readableLength &&
745
- outboundExchange
746
- ) {
747
- (inboundRequest as IncomingMessage).on("data", chunk =>
748
- outboundExchange.write(chunk),
749
- );
750
- (inboundRequest as IncomingMessage).on("end", () =>
751
- outboundExchange.end(),
752
- );
1127
+ if (!config.ssl && http1WithRequestBody && outboundExchange) {
1128
+ if (bufferedRequestBody) {
1129
+ outboundExchange.write(requestBody);
1130
+ outboundExchange.end();
1131
+ }
1132
+
1133
+ if (!bufferedRequestBody) {
1134
+ (inboundRequest as IncomingMessage).on("data", chunk => {
1135
+ outboundExchange.write(chunk);
1136
+ });
1137
+ (inboundRequest as IncomingMessage).on("end", () =>
1138
+ outboundExchange.end(),
1139
+ );
1140
+ }
753
1141
  }
754
1142
 
755
1143
  // phase : response headers
@@ -772,6 +1160,25 @@ const start = () => {
772
1160
  }),
773
1161
  );
774
1162
 
1163
+ notifyLogsListener({
1164
+ level: "info",
1165
+ protocol: http2IsSupported ? "HTTP/2" : "HTTP1.1",
1166
+ method: inboundRequest.method,
1167
+ path: fullPath,
1168
+ uniqueHash: Buffer.from(
1169
+ JSON.stringify({
1170
+ method: inboundRequest.method,
1171
+ url: inboundRequest.url,
1172
+ headers: Object.fromEntries(
1173
+ Object.entries(inboundRequest.headers).filter(
1174
+ ([headerName]) => !headerName.startsWith(":"),
1175
+ ),
1176
+ ),
1177
+ body: requestBody?.toJSON(),
1178
+ }),
1179
+ ).toString("base64"),
1180
+ });
1181
+
775
1182
  const newUrl = !outboundResponseHeaders["location"]
776
1183
  ? null
777
1184
  : new URL(
@@ -815,157 +1222,19 @@ const start = () => {
815
1222
  if (!config.replaceResponseBodyUrls) return payloadBuffer;
816
1223
  if (!payloadBuffer.length) return payloadBuffer;
817
1224
 
818
- return (outboundResponseHeaders["content-encoding"] || "")
819
- .split(",")
820
- .reduce(
821
- async (buffer: Promise<Buffer>, formatNotTrimed: string) => {
822
- const format = formatNotTrimed.trim().toLowerCase();
823
- const method =
824
- format === "gzip" || format === "x-gzip"
825
- ? gunzip
826
- : format === "deflate"
827
- ? inflate
828
- : format === "br"
829
- ? brotliDecompress
830
- : format === "identity" || format === ""
831
- ? (
832
- input: Buffer,
833
- callback: (err?: Error, data?: Buffer) => void,
834
- ) => {
835
- callback(null, input);
836
- }
837
- : null;
838
- if (method === null) {
839
- send(
840
- 502,
841
- inboundResponse,
842
- Buffer.from(
843
- errorPage(
844
- new Error(
845
- `${format} compression not supported by the proxy`,
846
- ),
847
- "stream",
848
- url,
849
- targetUrl,
850
- ),
851
- ),
852
- );
853
- return;
854
- }
855
-
856
- const openedBuffer = await buffer;
857
- return await new Promise<Buffer>(resolve =>
858
- method(openedBuffer, (err_1, data_1) => {
859
- if (err_1) {
860
- send(
861
- 502,
862
- inboundResponse,
863
- Buffer.from(
864
- errorPage(err_1, "stream", url, targetUrl),
865
- ),
866
- );
867
- resolve(Buffer.from(""));
868
- return;
869
- }
870
- resolve(data_1);
871
- }),
872
- );
873
- },
874
- Promise.resolve(payloadBuffer),
875
- )
876
- .then((uncompressedBuffer: Buffer) => {
877
- const fileTooBig = uncompressedBuffer.length > 1e7;
878
- const fileHasSpecialChars = () =>
879
- /[^\x00-\x7F]/.test(uncompressedBuffer.toString());
880
- const contentTypeCanBeProcessed = [
881
- "text/html",
882
- "application/javascript",
883
- "application/json",
884
- ].some(allowedContentType =>
885
- (outboundResponseHeaders["content-type"] ?? "").includes(
886
- allowedContentType,
887
- ),
888
- );
889
- const willReplace =
890
- !fileTooBig &&
891
- (contentTypeCanBeProcessed || !fileHasSpecialChars());
892
- return !willReplace
893
- ? uncompressedBuffer
894
- : !config.replaceResponseBodyUrls
895
- ? uncompressedBuffer.toString()
896
- : Object.entries(config.mapping)
897
- .reduce(
898
- (inProgress, [path, mapping]) =>
899
- path !== "" &&
900
- !path.match(/^[-a-zA-Z0-9()@:%_\+.~#?&//=]*$/)
901
- ? inProgress
902
- : inProgress.replace(
903
- new RegExp(
904
- mapping
905
- .replace(/^file:\/\//, "")
906
- .replace(/[*+?^${}()|[\]\\]/g, "")
907
- .replace(/^https/, "https?") + "/*",
908
- "ig",
909
- ),
910
- `https://${proxyHostnameAndPort}${path.replace(
911
- /\/+$/,
912
- "",
913
- )}/`,
914
- ),
915
- uncompressedBuffer.toString(),
916
- )
917
- .split(`${proxyHostnameAndPort}/:`)
918
- .join(`${proxyHostnameAndPort}:`)
919
- .replace(
920
- /\?protocol=wss?%3A&hostname=[^&]+&port=[0-9]+&pathname=/g,
921
- `?protocol=ws${
922
- config.ssl ? "s" : ""
923
- }%3A&hostname=${proxyHostname}&port=${
924
- config.port
925
- }&pathname=${encodeURIComponent(
926
- key.replace(/\/+$/, ""),
927
- )}`,
928
- );
929
- })
930
- .then((updatedBody: Buffer | string) =>
931
- (outboundResponseHeaders["content-encoding"] || "")
932
- .split(",")
933
- .reduce(
934
- (buffer: Promise<Buffer>, formatNotTrimed: string) => {
935
- const format = formatNotTrimed.trim().toLowerCase();
936
- const method =
937
- format === "gzip" || format === "x-gzip"
938
- ? gzip
939
- : format === "deflate"
940
- ? deflate
941
- : format === "br"
942
- ? brotliCompress
943
- : format === "identity" || format === ""
944
- ? (
945
- input: Buffer,
946
- callback: (err?: Error, data?: Buffer) => void,
947
- ) => {
948
- callback(null, input);
949
- }
950
- : null;
951
- if (method === null)
952
- throw new Error(
953
- `${format} compression not supported by the proxy`,
954
- );
955
-
956
- return buffer.then(
957
- data =>
958
- new Promise<Buffer>(resolve =>
959
- method(data, (err, data) => {
960
- if (err) throw err;
961
- resolve(data);
962
- }),
963
- ),
964
- );
965
- },
966
- Promise.resolve(Buffer.from(updatedBody)),
967
- ),
1225
+ return replaceBody(payloadBuffer, outboundResponseHeaders, {
1226
+ proxyHostnameAndPort,
1227
+ proxyHostname,
1228
+ key,
1229
+ direction: REPLACEMENT_DIRECTION.INBOUND,
1230
+ }).catch((e: Error) => {
1231
+ send(
1232
+ 502,
1233
+ inboundResponse,
1234
+ Buffer.from(errorPage(e, "stream", url, targetUrl)),
968
1235
  );
1236
+ return Buffer.from("");
1237
+ });
969
1238
  }));
970
1239
 
971
1240
  // phase : inbound response
@@ -1064,7 +1333,38 @@ const start = () => {
1064
1333
  return;
1065
1334
  }
1066
1335
 
1067
- const { key, target: targetWithForcedPrefix } = determineMapping(request);
1336
+ const {
1337
+ key,
1338
+ target: targetWithForcedPrefix,
1339
+ path,
1340
+ } = determineMapping(request);
1341
+
1342
+ if (path === "/local-traffic-logs") {
1343
+ const shasum = createHash("sha1");
1344
+ shasum.update(
1345
+ request.headers["sec-websocket-key"] +
1346
+ "258EAFA5-E914-47DA-95CA-C5AB0DC85B11",
1347
+ );
1348
+ const accept = shasum.digest("base64");
1349
+ upstreamSocket.allowHalfOpen = true;
1350
+ upstreamSocket.write(
1351
+ "HTTP/1.1 101 Switching Protocols\r\n" +
1352
+ `date: ${new Date().toUTCString()}\r\n` +
1353
+ "connection: upgrade\r\n" +
1354
+ "upgrade: websocket\r\n" +
1355
+ "server: local\r\n" +
1356
+ `sec-websocket-accept: ${accept}\r\n` +
1357
+ "\r\n",
1358
+ );
1359
+ upstreamSocket.on("close", () => {
1360
+ logsListeners = logsListeners.filter(
1361
+ oneLogsListener => upstreamSocket !== oneLogsListener,
1362
+ );
1363
+ });
1364
+ logsListeners.push(upstreamSocket);
1365
+ return;
1366
+ }
1367
+
1068
1368
  const target = new URL(
1069
1369
  `${targetWithForcedPrefix.protocol}//${targetWithForcedPrefix.host}${
1070
1370
  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.45",
3
+ "version": "0.0.47",
4
4
  "main": "index.ts",
5
5
  "private": false,
6
6
  "keywords": [
@@ -24,9 +24,9 @@
24
24
  "build": "npm run clean && npm run typescript && npm run terser && npm run shebang && npm run chmod"
25
25
  },
26
26
  "devDependencies": {
27
- "@types/node": "^18.15.3",
28
- "terser": "^5.16.6",
29
- "typescript": "^5.0.2"
27
+ "@types/node": "^18.15.11",
28
+ "terser": "^5.16.9",
29
+ "typescript": "^5.0.4"
30
30
  },
31
31
  "bin": {
32
32
  "local-traffic": "./dist/localTraffic.js"