local-traffic 0.0.46 → 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.
- package/dist/localTraffic.js +1 -1
- package/index.ts +509 -209
- package/package.json +4 -4
package/dist/localTraffic.js
CHANGED
|
@@ -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?"":"[36m"}${`${t.getHours()}`.padStart(2,"0")}${e?":":"[33m:[36m"}${`${t.getMinutes()}`.padStart(2,"0")}${e?":":"[33m:[36m"}${`${t.getSeconds()}`.padStart(2,"0")}${e?"":"[0m"}`})(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"),"web-security").replace(/\|+/g,"|"):t?`[48;5;${t}m⎸ ${process.stdout.isTTY&&r||""} ${e.padEnd(40)} ⎹[0m`:e}`)},g=e=>{m(`[48;5;52m⎸${l.PORT} ${e.port.toString().padStart(5)} [48;5;53m⎸${l.INBOUND} ${e.ssl?"H/2 ":"H1.1"} [48;5;54m⎸${l.OUTBOUND} ${e.dontUseHttp2Downstream?"H1.1":"H/2 "}⎹[48;5;55m⎸${l.RULES}${Object.keys(h.mapping).length.toString().padStart(3)}⎹[48;5;56m⎸${h.replaceResponseBodyUrls?l.BODY_REPLACEMENT:l.NO}⎹[48;5;57m⎸${h.websocket?l.WEBSOCKET:l.NO}⎹[48;5;93m⎸${h.simpleLogs?l.NO:l.COLORED}⎹[48;5;98m⎸${h.disableWebSecurity?l.NO:l.SHIELD}⎹[0m`)},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(`web 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,"/")}</i></p><ul class="list-group"><li class="list-group-item">📁<a href="${e.pathname.endsWith("/")?"..":"."}"><parent></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 ⓘ 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||"<no-target-url>"}</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?"":"[36m"}${`${t.getHours()}`.padStart(2,"0")}${e?":":"[33m:[36m"}${`${t.getMinutes()}`.padStart(2,"0")}${e?":":"[33m:[36m"}${`${t.getSeconds()}`.padStart(2,"0")}${e?"":"[0m"}`})(m?.simpleLogs)} ${m?.simpleLogs?n:t?`[48;5;${t}m⎸ ${process.stdout.isTTY&&r||""} ${e.padEnd(40)} ⎹[0m`: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(`[48;5;52m⎸${c.PORT} ${e.port.toString().padStart(5)} [48;5;53m⎸${c.INBOUND} ${e.ssl?"H/2 ":"H1.1"}${e.replaceRequestBodyUrls?c.REWRITE:" "}⎹[48;5;54m⎸${c.OUTBOUND} ${e.dontUseHttp2Downstream?"H1.1":"H/2 "}${e.replaceResponseBodyUrls?c.REWRITE:" "}⎹[48;5;55m⎸${c.RULES}${Object.keys(m.mapping).length.toString().padStart(3)}⎹[48;5;56m⎸${m.websocket?c.WEBSOCKET:c.NO}⎹[48;5;57m⎸${m.simpleLogs?c.NO:c.COLORED}⎹[48;5;93m⎸${m.disableWebSecurity?c.NO:c.SHIELD}⎹[0m`)},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,"/")}</i></p><ul class="list-group"><li class="list-group-item">📁<a href="${e.pathname.endsWith("/")?"..":"."}"><parent></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, '&').replace(/\\\\"/g, '"')\n .replace(/</g, '<').replace(/>/g, '>')\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 ⓘ 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||"<no-target-url>"}</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
|
|
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
|
-
|
|
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
|
-
?
|
|
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
|
-
}
|
|
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;
|
|
201
|
+
}⎹\u001b[48;5;57m⎸${
|
|
148
202
|
!config.simpleLogs ? EMOJIS.COLORED : EMOJIS.NO
|
|
149
|
-
}⎹\u001b[48;5;
|
|
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
|
-
}
|
|
292
|
+
}rewriting`,
|
|
230
293
|
LogLevel.INFO,
|
|
231
|
-
EMOJIS.
|
|
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, '&').replace(/\\\\"/g, '"')
|
|
526
|
+
.replace(/</g, '<').replace(/>/g, '>')
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
717
|
-
outboundHttp1Request.write(
|
|
718
|
-
|
|
719
|
-
|
|
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
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
(
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
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
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
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
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
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 {
|
|
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.
|
|
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.
|
|
28
|
-
"terser": "^5.16.
|
|
29
|
-
"typescript": "^5.0.
|
|
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"
|