local-traffic 0.0.31 → 0.0.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -62,3 +62,4 @@ npx local-traffic [location-of-the-local-traffic-config-file]
62
62
  - "replaceResponseBodyUrls": (boolean) replace every matching string from the mapping in the response body.
63
63
  - "dontUseHttp2Downstream": (boolean) force calling downstream services in http1.1 only (to save some time)
64
64
  - "simpleLogs": (boolean) disable colored logs for text terminals
65
+ - "websocket": (boolean) true to activate websocket connections proxying via sockets
@@ -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"),n=require("url"),o=require("fs"),s=require("zlib"),i=require("path");var a;!function(e){e[e.ERROR=124]="ERROR",e[e.SUCCESS=35]="SUCCESS",e[e.INFO=21]="INFO",e[e.WARNING=172]="WARNING"}(a||(a={}));const l=(0,i.resolve)(process.env.HOME,".local-traffic.json"),p=(0,i.resolve)(process.cwd(),process.argv.slice(-1)[0].endsWith(".json")?process.argv.slice(-1)[0]:l),c={mapping:{},port:8080,replaceResponseBodyUrls:!1,dontUseHttp2Downstream:!1,simpleLogs:!1};let d,h;const u=(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?"":""}`})(d.simpleLogs)} ${d.simpleLogs?e.replace(/⎸/g,"|").replace(/⎹/g,"|").replace(/\u001b\[[^m]*m/g,"").replace(/↘️/g,"inbound").replace(/☎️/g,"port").replace(/↗️/g,"outbound"):t?`[48;5;${t}m⎸ ${process.stdout.isTTY&&r||""} ${e.padEnd(36)} ⎹`:e}`)},m=async(e=!0)=>new Promise((t=>(0,o.readFile)(p,((r,n)=>{r&&!e&&u("config error. Using default value",a.ERROR,"❌");try{d=Object.assign({},c,JSON.parse((n||"{}").toString()))}catch(e){return u("config syntax incorrect, aborting",a.ERROR,"⛈️"),d=d||{...c},void t(d)}d.mapping[""]||u('default mapping "" not provided.',a.WARNING,"☢️"),r&&"ENOENT"===r.code&&e&&p===l?(0,o.writeFile)(p,JSON.stringify(c),(e=>{e?u("config file NOT created",a.ERROR,"⁉️"):u("config file created",a.SUCCESS,"✨"),t(d)})):t(d)})))).then((()=>{e&&(0,o.watchFile)(p,f)})),g=e=>{u(`⎸ ↘️ : ${e.ssl?"HTTP/2 ":"HTTP 1.1"} ⎸ ☎️ : ${e.port.toString().padStart(5)} ⎸ ↗️ : ${e.dontUseHttp2Downstream?"HTTP 1.1":"HTTP/2 "} ⎹`)},f=async()=>{const e={...d};return await m(!1),isNaN(d.port)||d.port>65535||d.port<0?(d=e,void u("port number invalid. Not refreshing",a.ERROR,"☎️")):"object"!=typeof d.mapping?(d=e,void u("mapping should be an object. Aborting",a.ERROR,"⚡")):(d.replaceResponseBodyUrls&&!e.replaceResponseBodyUrls&&u("response body url replacement",a.INFO,"✔️"),!d.replaceResponseBodyUrls&&e.replaceResponseBodyUrls&&u("response body url NO replacement",a.INFO,"✖️"),u(`${Object.keys(d.mapping).length.toString().padStart(5)} loaded mapping rules`,a.SUCCESS,"↻"),void(d.port!==e.port||JSON.stringify(d.ssl)!==JSON.stringify(e.ssl)?(await new Promise((e=>h?h.close(e):e(void 0))),v()):d.dontUseHttp2Downstream!==e.dontUseHttp2Downstream&&g(d)))},$=e=>""==e?"":(0,i.normalize)(e).replace(/\\/g,"/"),b=e=>{const t=(0,i.resolve)("/",e.hostname,...e.pathname.replace(/[?#].*$/,"").replace(/^\/+/,"").split("/"));return{error:null,data:null,hasRun:!1,run:function(){return this.hasRun?Promise.resolve():new Promise((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,i.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=`${y(128194,"directory",e.href)}\n <p>Directory content of <i>${e.href.replace(/\//g,"&#x002F;")}</i></p>\n <ul class="list-group">\n <li class="list-group-item">&#x1F4C1;<a href="${e.pathname.endsWith("/")?"..":"."}">&lt;parent&gt;</a></li>\n ${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")}\n </li>\n </ul>\n </body></html>`,r(void 0)}))}))}))))},events:{},on:function(e,r){return this.events[e]=r,this.run().then((()=>{"response"===e&&this.events.response({Server:"local","Content-Type":t.endsWith(".svg")?"image/svg+xml":null},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}}},y=(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/>`,w=(e,t,r,n)=>`${y(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>`,R=(e,t,r)=>{t.writeHead(e,void 0,{"content-type":"text/html","content-length":r.length}),t.end(r)},v=()=>{h=(d.ssl?e.createSecureServer.bind(null,{...d.ssl,allowHTTP1:!0}):t.createServer)((async(o,i)=>{if(!o.headers.host&&!o.headers[":authority"])return void R(400,i,Buffer.from(w(new Error("client must supply a 'host' header"),"proxy",new n.URL(`http${d.ssl?"s":""}://unknowndomain${o.url}`))));const a=o.headers[":authority"]||`${o.headers.host}${o.headers.host.match(/:[0-9]+$/)?"":80!==d.port||d.ssl?443===d.port&&d.ssl?"":`:${d.port}`:""}`,l=new n.URL(`http${d.ssl?"s":""}://${a}${o.url}`),c=l.href.substring(l.origin.length),[h,u]=Object.entries({...Object.assign({},...Object.entries(d.mapping).map((([e,t])=>({[e]:new n.URL($(t))}))))}).find((([e])=>c.match(RegExp(e))))||[];if(!u)return void R(502,i,Buffer.from(w(new Error(`No mapping found in config file ${p}`),"proxy",l)));const m=u.host.replace(RegExp(/\/+$/),""),g=`${u.href.substring("https://".length+u.host.length)}${$(c.replace(RegExp($(h)),""))}`.replace(/^\/*/,"/"),f=new n.URL(`${u.protocol}//${m}${g}`);let y=null,v=!d.dontUseHttp2Downstream;const S="file:"===u.protocol?b(f):v?await Promise.race([new Promise((t=>{const r=(0,e.connect)(f,{rejectUnauthorized:!1,protocol:u.protocol},((e,n)=>{v=v&&!!n.alpnProtocol,t(v?r:null)}));r.on("error",(e=>{y=v&&Buffer.from(w(e,"connection",l,f))}))})),new Promise((e=>setTimeout((()=>{v=!1,e(null)}),3e3)))]):null;!v&&y&&(y=null);const O={...[...Object.entries(o.headers)].filter((([e])=>!["host","connection"].includes(e.toLowerCase()))).reduce(((e,[t,r])=>(e[t]=(e[t]||"")+(Array.isArray(r)?r:[r]).map((e=>e.replace(l.hostname,m))).join(", "),e)),{}),origin:u.href,referer:f.toString(),":authority":m,":method":o.method,":path":g,":scheme":u.protocol.replace(":","")},E=S&&!y&&S.request(O,{endStream:d.ssl?!(o?.stream?.readableLength??1):!o.readableLength});E&&E.on("error",(e=>{const t=-505===e.errno;y=Buffer.from(w(e,"stream"+(t?" (error -505 usually means that the downstream service does not support this http version)":""),l,f))}));const j={hostname:u.hostname,path:g,port:u.port,protocol:u.protocol,rejectUnauthorized:!1,method:o.method,headers:{...Object.assign({},...Object.entries(O).filter((([e])=>!e.startsWith(":")&&"transfer-encoding"!==e.toLowerCase())).map((([e,t])=>({[e]:t})))),host:u.hostname}},U=!y&&!v&&"file:"!==u.protocol&&await new Promise((e=>{const n="https:"===u.protocol?(0,r.request)(j,e):(0,t.request)(j,e);n.on("error",(t=>{y=Buffer.from(w(t,"request",l,f)),e(null)})),o.on("data",(e=>n.write(e))),o.on("end",(()=>n.end()))}));if(y)return void R(502,i,y);d.ssl&&o.stream&&o.stream.readableLength&&E&&(o.stream.on("data",(e=>E.write(e))),o.stream.on("end",(()=>E.end()))),!d.ssl&&o.readableLength&&E&&(o.on("data",(e=>E.write(e))),o.on("end",(()=>E.end())));const{outboundResponseHeaders:x}=await new Promise((e=>E?E.on("response",(t=>{e({outboundResponseHeaders:t})})):e(!E&&U?{outboundResponseHeaders:U.headers}:{outboundResponseHeaders:{}}))),N=x.location?new n.URL(x.location.startsWith("/")?`${u.href}${x.location.replace(/^\/+/,"")}`:x.location):null,L=N?N.href.substring(N.origin.length):null,P=l.origin,C=N?`${P}${L}`:null,B=E||U,H=y??await new Promise((e=>{let t=Buffer.alloc(0);B?(B.on("data",(e=>t=Buffer.concat([t,"string"==typeof e?Buffer.from(e):e]))),B.on("end",(()=>{e(t)}))):e(t)})).then((e=>d.replaceResponseBodyUrls&&e.length?(x["content-encoding"]||"").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)return void R(502,i,Buffer.from(w(new Error(`${r} compression not supported by the proxy`),"stream",l,f)));const o=await e;return await new Promise((e=>n(o,((t,r)=>{if(t)return R(502,i,Buffer.from(w(t,"stream",l,f))),void e("");e(r)}))))}),Promise.resolve(e)).then((e=>e.length>1e6||/[^\x00-\x7F]/.test(e.toString())&&!(x["content-type"]??"").includes("text/html")?e:d.replaceResponseBodyUrls?Object.entries(d.mapping).reduce(((e,[t,r])=>t.match(/^[-a-zA-Z0-9()@:%_\+.~#?&//=]*$/)||""===t?e.replace(new RegExp(r.replace(/^file:\/\//,"").replace(/[*+?^${}()|[\]\\]/g,"").replace(/^https/,"https?"),"ig"),`https://${a}${t.replace(/\/+$/,"")}/`):e),e.toString()).split(`${a}/:`).join(`${a}:`):e.toString())).then((e=>(x["content-encoding"]||"").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))))):e)),T={...Object.entries({...x,...d.replaceResponseBodyUrls?{"content-length":`${H.byteLength}`}:{}}).filter((([e])=>!e.startsWith(":")&&"transfer-encoding"!==e.toLowerCase()&&"connection"!==e.toLowerCase())).reduce(((e,[t,r])=>{const n=m.split("").map(((e,t)=>m.substring(t).startsWith(".")&&m.substring(t))).filter((e=>e)),o=[m].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}),{}),...C?{location:[C]}:{}};try{Object.entries(T).forEach((([e,t])=>t&&i.setHeader(e,t)))}catch(e){}i.writeHead(x[":status"]||U.statusCode||200,d.ssl?void 0:U.statusMessage||"Status read from http/2",T),H?i.end(H):i.end()})).addListener("error",(e=>{"EACCES"===e.code&&u("permission denied for this port",a.ERROR,"⛔"),"EADDRINUSE"===e.code&&u("port is already used. NOT started",a.ERROR,"☠️")})).addListener("listening",(()=>{g(d)})).listen(d.port)};m().then(v);
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;!function(e){e[e.ERROR=124]="ERROR",e[e.SUCCESS=35]="SUCCESS",e[e.INFO=21]="INFO",e[e.WARNING=172]="WARNING"}(i||(i={}));const l=(0,a.resolve)(process.env.HOME,".local-traffic.json"),p=(0,a.resolve)(process.cwd(),process.argv.slice(-1)[0].endsWith(".json")?process.argv.slice(-1)[0]:l),c={mapping:{},port:8080,replaceResponseBodyUrls:!1,dontUseHttp2Downstream:!1,simpleLogs:!1,websocket:!1};let d,h;const u=(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?"":""}`})(d.simpleLogs)} ${d.simpleLogs?e.replace(/⎸/g,"|").replace(/⎹/g,"|").replace(/\u001b\[[^m]*m/g,"").replace(/↘️/g,"inbound").replace(/☎️/g,"port").replace(/↗️/g,"outbound"):t?`[48;5;${t}m⎸ ${process.stdout.isTTY&&r||""} ${e.padEnd(36)} ⎹`:e}`)},m=async(e=!0)=>new Promise((t=>(0,n.readFile)(p,((r,o)=>{r&&!e&&u("config error. Using default value",i.ERROR,"❌");try{d=Object.assign({},c,JSON.parse((o||"{}").toString()))}catch(e){return u("config syntax incorrect, aborting",i.ERROR,"⛈️"),d=d||{...c},void t(d)}d.mapping[""]||u('default mapping "" not provided.',i.WARNING,"☢️"),r&&"ENOENT"===r.code&&e&&p===l?(0,n.writeFile)(p,JSON.stringify(c),(e=>{e?u("config file NOT created",i.ERROR,"⁉️"):u("config file created",i.SUCCESS,"✨"),t(d)})):t(d)})))).then((()=>{e&&(0,n.watchFile)(p,f)})),g=e=>{u(`⎸ ↘️ : ${e.ssl?"HTTP/2 ":"HTTP 1.1"} ⎸ ☎️ : ${e.port.toString().padStart(5)} ⎸ ↗️ : ${e.dontUseHttp2Downstream?"HTTP 1.1":"HTTP/2 "} ⎹`)},f=async()=>{const e={...d};return await m(!1),isNaN(d.port)||d.port>65535||d.port<0?(d=e,void u("port number invalid. Not refreshing",i.ERROR,"☎️")):"object"!=typeof d.mapping?(d=e,void u("mapping should be an object. Aborting",i.ERROR,"⚡")):(d.replaceResponseBodyUrls&&!e.replaceResponseBodyUrls&&u("response body url replacement",i.INFO,"✔️"),!d.replaceResponseBodyUrls&&e.replaceResponseBodyUrls&&u("response body url NO replacement",i.INFO,"✖️"),d.websocket&&!e.websocket&&u("websocket activated",i.INFO,"☄️"),!d.websocket&&e.websocket&&u("websocket deactivated",i.INFO,"☄️"),u(`${Object.keys(d.mapping).length.toString().padStart(5)} loaded mapping rules`,i.SUCCESS,"↻"),void(d.port!==e.port||JSON.stringify(d.ssl)!==JSON.stringify(e.ssl)?(await new Promise((e=>h?h.close(e):e(void 0))),S()):d.dontUseHttp2Downstream!==e.dontUseHttp2Downstream&&g(d)))},$=e=>""==e?"":(0,a.normalize)(e).replace(/\\/g,"/"),w=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=`${b(128194,"directory",e.href)}\n <p>Directory content of <i>${e.href.replace(/\//g,"&#x002F;")}</i></p>\n <ul class="list-group">\n <li class="list-group-item">&#x1F4C1;<a href="${e.pathname.endsWith("/")?"..":"."}">&lt;parent&gt;</a></li>\n ${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")}\n </li>\n </ul>\n </body></html>`,r(void 0)}))}))}))))},events:{},on:function(e,r){return this.events[e]=r,this.run().then((()=>{"response"===e&&this.events.response({Server:"local","Content-Type":t.endsWith(".svg")?"image/svg+xml":null},0),"data"===e&&this.data&&(this.events.data(this.data),this.events.end()),"error"===e&&this.error&&this.events.error(this.error)})),this},end:function(){return this},request:function(){return this}}},b=(e,t,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/>`,y=(e,t,r,o)=>`${b(128163,"error",e.message)}\n<p>An error happened while trying to proxy a remote exchange</p>\n<div class="alert alert-warning" role="alert">\n &#x24D8;&nbsp;This is not an error from the downstream service.\n</div>\n<div class="alert alert-danger" role="alert">\n<pre><code>${e.stack||`<i>${e.name} : ${e.message}</i>`}${e.errno?`<br/>(code : ${e.errno})`:""}</code></pre>\n</div>\nMore information about the request :\n<table class="table">\n <tbody>\n <tr>\n <td>phase</td>\n <td>${t}</td>\n </tr>\n <tr>\n <td>requested URL</td>\n <td>${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>`,R=(e,t,r)=>{t.writeHead(e,void 0,{"content-type":"text/html","content-length":r.length}),t.end(r)},v=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!==d.port||d.ssl?443===d.port&&d.ssl?"":`:${d.port}`:""}`,n=new o.URL(`http${d.ssl?"s":""}://${r}${e.url}`),s=n.href.substring(n.origin.length),[a,i]=Object.entries({...Object.assign({},...Object.entries(d.mapping).map((([e,t])=>({[e]:new o.URL($(t))}))))}).find((([e])=>s.match(RegExp(e))))||[];return{proxyHostname:t,proxyHostnameAndPort:r,url:n,path:s,key:a,target:i}},S=()=>{h=(d.ssl?e.createSecureServer.bind(null,{...d.ssl,allowHTTP1:!0}):t.createServer)((async(n,a)=>{if(!n.headers.host&&!n.headers[":authority"])return void R(400,a,Buffer.from(y(new Error("client must supply a 'host' header"),"proxy",new o.URL(`http${d.ssl?"s":""}://unknowndomain${n.url}`))));const{proxyHostname:i,proxyHostnameAndPort:l,url:c,path:h,key:u,target:m}=v(n);if(!m)return void R(502,a,Buffer.from(y(new Error(`No mapping found in config file ${p}`),"proxy",c)));const g=m.host.replace(RegExp(/\/+$/),""),f=`${m.href.substring("https://".length+m.host.length)}${$(h.replace(RegExp($(u)),""))}`.replace(/^\/*/,"/"),b=new o.URL(`${m.protocol}//${g}${f}`);let S=null,O=!d.dontUseHttp2Downstream;const j="file:"===m.protocol?w(b):O?await Promise.race([new Promise((t=>{const r=(0,e.connect)(b,{rejectUnauthorized:!1,protocol:m.protocol},((e,o)=>{O=O&&!!o.alpnProtocol,t(O?r:null)}));r.on("error",(e=>{S=O&&Buffer.from(y(e,"connection",c,b))}))})),new Promise((e=>setTimeout((()=>{O=!1,e(null)}),3e3)))]):null;S instanceof Buffer||(S=null);const E={...[...Object.entries(n.headers)].filter((([e])=>!["host","connection"].includes(e.toLowerCase()))).reduce(((e,[t,r])=>(e[t]=(e[t]||"")+(Array.isArray(r)?r:[r]).map((e=>e.replace(c.hostname,g))).join(", "),e)),{}),origin:m.href,referer:b.toString(),":authority":g,":method":n.method,":path":f,":scheme":m.protocol.replace(":","")},N=j&&!S&&j.request(E,{endStream:d.ssl?!(n?.stream?.readableLength??1):!n.readableLength});N&&N.on("error",(e=>{const t=-505===e.errno;S=Buffer.from(y(e,"stream"+(t?" (error -505 usually means that the downstream service does not support this http version)":""),c,b))}));const U={hostname:m.hostname,path:f,port:m.port,protocol:m.protocol,rejectUnauthorized:!1,method:n.method,headers:{...Object.assign({},...Object.entries(E).filter((([e])=>!e.startsWith(":")&&"transfer-encoding"!==e.toLowerCase())).map((([e,t])=>({[e]:t})))),host:m.hostname}},x=!S&&!O&&"file:"!==m.protocol&&await new Promise((e=>{const o="https:"===m.protocol?(0,r.request)(U,e):(0,t.request)(U,e);o.on("error",(t=>{S=Buffer.from(y(t,"request",c,b)),e(null)})),n.on("data",(e=>o.write(e))),n.on("end",(()=>o.end()))}));if(S)return void R(502,a,S);S=null,d.ssl&&n.stream&&n.stream.readableLength&&N&&(n.stream.on("data",(e=>N.write(e))),n.stream.on("end",(()=>N.end()))),!d.ssl&&n.readableLength&&N&&(n.on("data",(e=>N.write(e))),n.on("end",(()=>N.end())));const{outboundResponseHeaders:H}=await new Promise((e=>N?N.on("response",(t=>{e({outboundResponseHeaders:t})})):e(!N&&x?{outboundResponseHeaders:x.headers}:{outboundResponseHeaders:{}}))),P=H.location?new o.URL(H.location.startsWith("/")?`${m.href}${H.location.replace(/^\/+/,"")}`:H.location):null,L=P?P.href.substring(P.origin.length):null,C=c.origin,T=P?`${C}${L}`:null,A=N||x,B=S??await new Promise((e=>{let t=Buffer.alloc(0);A?(A.on("data",(e=>t=Buffer.concat([t,"string"==typeof e?Buffer.from(e):e]))),A.on("end",(()=>{e(t)}))):e(t)})).then((e=>d.replaceResponseBodyUrls&&e.length?(H["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 R(502,a,Buffer.from(y(new Error(`${r} compression not supported by the proxy`),"stream",c,b)));const n=await e;return await new Promise((e=>o(n,((t,r)=>{if(t)return R(502,a,Buffer.from(y(t,"stream",c,b))),void e("");e(r)}))))}),Promise.resolve(e)).then((e=>{const t=e.length>1e7,r=["text/html","application/javascript","application/json"].some((e=>(H["content-type"]??"").includes(e)));return!t&&(r||!/[^\x00-\x7F]/.test(e.toString()))?d.replaceResponseBodyUrls?Object.entries(d.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${d.ssl?"s":""}%3A&hostname=${i}&port=${d.port}&pathname=${encodeURIComponent(u.replace(/\/+$/,""))}`):e.toString():e})).then((e=>(H["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)),q={...Object.entries({...H,...d.replaceResponseBodyUrls?{"content-length":`${B.byteLength}`}:{}}).filter((([e])=>!e.startsWith(":")&&"transfer-encoding"!==e.toLowerCase()&&"connection"!==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=${c.hostname}`):e))),r);return e[t]=(e[t]||[]).concat(n),e}),{}),...T?{location:[T]}:{}};try{Object.entries(q).forEach((([e,t])=>t&&a.setHeader(e,t)))}catch(e){}a.writeHead(H[":status"]||x.statusCode||200,d.ssl?void 0:x.statusMessage||"Status read from http/2",q),B?a.end(B):a.end()})).addListener("error",(e=>{"EACCES"===e.code&&u("permission denied for this port",i.ERROR,"⛔"),"EADDRINUSE"===e.code&&u("port is already used. NOT started",i.ERROR,"☠️")})).addListener("listening",(()=>{g(d)})).on("upgrade",((e,n)=>{if(!d.websocket)return void n.end("HTTP/1.1 503 Service Unavailable\r\n\r\n");const{key:s,target:a}=v(e),l=new o.URL(`${a.protocol}//${a.host}${e.url.replace(new RegExp(`^${s}`,"g"),"").replace(/^\/*/,"/")}`),p={hostname:l.hostname,path:l.pathname,port:l.port,protocol:l.protocol,rejectUnauthorized:!1,method:e.method,headers:e.headers,host:l.hostname},c="https:"===l.protocol?(0,r.request)(p):(0,t.request)(p);c.end(),c.on("error",(e=>{u("websocket request has errored "+(e.errno?`(${e.errno})`:""),i.WARNING,"☄️")})),c.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=>{u("downstream socket has errored "+(e.errno?`(${e.errno})`:""),i.WARNING,"☄️")})),n.on("error",(e=>{u("upstream socket has errored "+(e.errno?`(${e.errno})`:""),i.WARNING,"☄️")}))}))})).listen(d.port)};m().then(S);
package/index.ts CHANGED
@@ -33,6 +33,8 @@ import {
33
33
  import { resolve, normalize } from "path";
34
34
  import type { Duplex } from "stream";
35
35
 
36
+ type ErrorWithErrno = NodeJS.ErrnoException;
37
+
36
38
  enum LogLevel {
37
39
  ERROR = 124,
38
40
  SUCCESS = 35,
@@ -47,6 +49,7 @@ interface LocalConfiguration {
47
49
  replaceResponseBodyUrls?: boolean;
48
50
  dontUseHttp2Downstream?: boolean;
49
51
  simpleLogs?: boolean;
52
+ websocket?: boolean;
50
53
  }
51
54
 
52
55
  const userHomeConfigFile = resolve(process.env.HOME, ".local-traffic.json");
@@ -62,6 +65,7 @@ const defaultConfig: LocalConfiguration = {
62
65
  replaceResponseBodyUrls: false,
63
66
  dontUseHttp2Downstream: false,
64
67
  simpleLogs: false,
68
+ websocket: false,
65
69
  };
66
70
 
67
71
  let config: LocalConfiguration;
@@ -172,6 +176,18 @@ const onWatch = async () => {
172
176
  ) {
173
177
  log("response body url NO replacement", LogLevel.INFO, "✖️");
174
178
  }
179
+ if (
180
+ config.websocket &&
181
+ !previousConfig.websocket
182
+ ) {
183
+ log("websocket activated", LogLevel.INFO, "☄️");
184
+ }
185
+ if (
186
+ !config.websocket &&
187
+ previousConfig.websocket
188
+ ) {
189
+ log("websocket deactivated", LogLevel.INFO, "☄️");
190
+ }
175
191
  log(
176
192
  `${Object.keys(config.mapping)
177
193
  .length.toString()
@@ -348,7 +364,7 @@ const errorPage = (
348
364
  </div>
349
365
  <div class="alert alert-danger" role="alert">
350
366
  <pre><code>${thrown.stack || `<i>${thrown.name} : ${thrown.message}</i>`}${
351
- (thrown as any).errno ? `<br/>(code : ${(thrown as any).errno})` : ""
367
+ (thrown as ErrorWithErrno).errno ? `<br/>(code : ${(thrown as ErrorWithErrno).errno})` : ""
352
368
  }</code></pre>
353
369
  </div>
354
370
  More information about the request :
@@ -386,8 +402,39 @@ const send = (
386
402
  inboundResponse.end(errorBuffer);
387
403
  };
388
404
 
405
+ const determineMapping = (inboundRequest: Http2ServerRequest | IncomingMessage): {
406
+ proxyHostname: string,
407
+ proxyHostnameAndPort: string,
408
+ url: URL,
409
+ path: string,
410
+ key: string,
411
+ target: URL
412
+ } => {
413
+
414
+ const proxyHostname =
415
+ (inboundRequest.headers[":authority"]?.toString() ??
416
+ inboundRequest.headers.host ?? 'localhost').replace(/:.*/, '');
417
+ const proxyHostnameAndPort =
418
+ inboundRequest.headers[":authority"] as string ||
419
+ `${inboundRequest.headers.host}${inboundRequest.headers.host.match(/:[0-9]+$/)
420
+ ? ""
421
+ : config.port === 80 && !config.ssl
422
+ ? ""
423
+ : config.port === 443 && config.ssl
424
+ ? ""
425
+ : `:${config.port}`
426
+ }`;
427
+ const url = new URL(
428
+ `http${config.ssl ? "s" : ""}://${proxyHostnameAndPort}${inboundRequest.url}`
429
+ );
430
+ const path = url.href.substring(url.origin.length);
431
+ const [key, target] =
432
+ Object.entries(envs()).find(([key]) => path.match(RegExp(key))) || [];
433
+ return { proxyHostname, proxyHostnameAndPort, url, path, key, target };
434
+ }
435
+
389
436
  const start = () => {
390
- server = (config.ssl
437
+ server = ((config.ssl
391
438
  ? createSecureServer.bind(null, { ...config.ssl, allowHTTP1: true })
392
439
  : createServer)(
393
440
  async (
@@ -409,23 +456,8 @@ const start = () => {
409
456
  );
410
457
  return;
411
458
  }
412
- const proxyHostname =
413
- inboundRequest.headers[":authority"] ||
414
- `${inboundRequest.headers.host}${
415
- inboundRequest.headers.host.match(/:[0-9]+$/)
416
- ? ""
417
- : config.port === 80 && !config.ssl
418
- ? ""
419
- : config.port === 443 && config.ssl
420
- ? ""
421
- : `:${config.port}`
422
- }`;
423
- const url = new URL(
424
- `http${config.ssl ? "s" : ""}://${proxyHostname}${inboundRequest.url}`
425
- );
426
- const path = url.href.substring(url.origin.length);
427
- const [key, target] =
428
- Object.entries(envs()).find(([key]) => path.match(RegExp(key))) || [];
459
+ const { proxyHostname, proxyHostnameAndPort, url, path, key, target } =
460
+ determineMapping(inboundRequest);
429
461
  if (!target) {
430
462
  send(
431
463
  502,
@@ -489,7 +521,7 @@ const start = () => {
489
521
  }, 3000)
490
522
  ),
491
523
  ]);
492
- if (!http2IsSupported && error) error = null;
524
+ if (!(error instanceof Buffer)) error = null;
493
525
 
494
526
  const outboundHeaders: OutgoingHttpHeaders = {
495
527
  ...[...Object.entries(inboundRequest.headers)]
@@ -526,7 +558,7 @@ const start = () => {
526
558
  ((outboundExchange as unknown) as Http2Stream).on(
527
559
  "error",
528
560
  (thrown: Error) => {
529
- const httpVersionSupported = (thrown as any).errno === -505;
561
+ const httpVersionSupported = (thrown as ErrorWithErrno).errno === -505;
530
562
  error = Buffer.from(
531
563
  errorPage(
532
564
  thrown,
@@ -581,11 +613,11 @@ const start = () => {
581
613
  );
582
614
  inboundRequest.on("end", () => outboundHttp1Request.end());
583
615
  }));
584
-
616
+ // intriguingly, error is reset to "false" at this point, even if it was null
585
617
  if (error) {
586
618
  send(502, inboundResponse, error);
587
619
  return;
588
- }
620
+ } else error = null;
589
621
 
590
622
  // phase : request body
591
623
  if (
@@ -728,37 +760,44 @@ const start = () => {
728
760
  })
729
761
  );
730
762
  }, Promise.resolve(payloadBuffer))
731
- .then((uncompressedBuffer: Buffer) =>
732
- (uncompressedBuffer.length > 1E6 ||
733
- /[^\x00-\x7F]/.test(uncompressedBuffer.toString()) &&
734
- !(outboundResponseHeaders["content-type"] ?? "").includes('text/html')) ?
763
+ .then((uncompressedBuffer: Buffer) => {
764
+ const fileTooBig = uncompressedBuffer.length > 1E7;
765
+ const fileHasSpecialChars = () => /[^\x00-\x7F]/.test(uncompressedBuffer.toString());
766
+ const contentTypeCanBeProcessed =
767
+ ['text/html', 'application/javascript', 'application/json'].some(allowedContentType =>
768
+ (outboundResponseHeaders["content-type"] ?? "").includes(allowedContentType));
769
+ const willReplace = !fileTooBig && (contentTypeCanBeProcessed || !fileHasSpecialChars());
770
+ return !willReplace ?
735
771
  uncompressedBuffer :
736
772
  !config.replaceResponseBodyUrls
737
773
  ? uncompressedBuffer.toString()
738
774
  : Object.entries(config.mapping)
739
775
  .reduce(
740
776
  (inProgress, [path, mapping]) =>
741
- !path.match(/^[-a-zA-Z0-9()@:%_\+.~#?&//=]*$/) &&
742
- path !== ''
777
+ path !== '' && !path.match(/^[-a-zA-Z0-9()@:%_\+.~#?&//=]*$/)
743
778
  ? inProgress
744
779
  : inProgress.replace(
745
780
  new RegExp(
746
781
  mapping
747
782
  .replace(/^file:\/\//, "")
748
783
  .replace(/[*+?^${}()|[\]\\]/g, "")
749
- .replace(/^https/, 'https?'),
784
+ .replace(/^https/, 'https?') + '/*',
750
785
  "ig"
751
786
  ),
752
- `https://${proxyHostname}${path.replace(
787
+ `https://${proxyHostnameAndPort}${path.replace(
753
788
  /\/+$/,
754
789
  ""
755
790
  )}/`
756
791
  ),
757
792
  uncompressedBuffer.toString()
758
793
  )
759
- .split(`${proxyHostname}/:`)
760
- .join(`${proxyHostname}:`)
761
- )
794
+ .split(`${proxyHostnameAndPort}/:`)
795
+ .join(`${proxyHostnameAndPort}:`)
796
+ .replace(/\?protocol=wss?%3A&hostname=[^&]+&port=[0-9]+&pathname=/g,
797
+ `?protocol=ws${config.ssl ?
798
+ "s" : ""}%3A&hostname=${proxyHostname}&port=${config.port}&pathname=${
799
+ encodeURIComponent(key.replace(/\/+$/, ''))}`)
800
+ })
762
801
  .then((updatedBody: Buffer | string) =>
763
802
  (outboundResponseHeaders["content-encoding"] || "")
764
803
  .split(",")
@@ -859,16 +898,70 @@ const start = () => {
859
898
  if (payload) inboundResponse.end(payload);
860
899
  else inboundResponse.end();
861
900
  }
862
- )
901
+ ) as Server)
863
902
  .addListener("error", (err: Error) => {
864
- if ((err as any).code === "EACCES")
903
+ if ((err as ErrorWithErrno).code === "EACCES")
865
904
  log(`permission denied for this port`, LogLevel.ERROR, "⛔");
866
- if ((err as any).code === "EADDRINUSE")
905
+ if ((err as ErrorWithErrno).code === "EADDRINUSE")
867
906
  log(`port is already used. NOT started`, LogLevel.ERROR, "☠️");
868
907
  })
869
908
  .addListener("listening", () => {
870
909
  logProtocols(config);
871
910
  })
911
+ .on("upgrade", (request: IncomingMessage, upstreamSocket: Duplex) => {
912
+ if (!config.websocket) {
913
+ upstreamSocket.end(`HTTP/1.1 503 Service Unavailable\r\n\r\n`)
914
+ return;
915
+ }
916
+
917
+ const { key, target: targetWithForcedPrefix } = determineMapping(request);
918
+ const target = new URL(`${targetWithForcedPrefix.protocol}//${
919
+ targetWithForcedPrefix.host}${request.url.replace(
920
+ new RegExp(`^${key}`, 'g'), '').replace(/^\/*/, '/')}`);
921
+ const downstreamRequestOptions: RequestOptions = {
922
+ hostname: target.hostname,
923
+ path: target.pathname,
924
+ port: target.port,
925
+ protocol: target.protocol,
926
+ rejectUnauthorized: false,
927
+ method: request.method,
928
+ headers: request.headers,
929
+ host: target.hostname,
930
+ };
931
+
932
+ const downstreamRequest = target.protocol === "https:"
933
+ ? httpsRequest(downstreamRequestOptions)
934
+ : httpRequest(downstreamRequestOptions);
935
+ downstreamRequest.end();
936
+ downstreamRequest.on('error', (error) => {
937
+ log(`websocket request has errored ${
938
+ (error as ErrorWithErrno).errno ?
939
+ `(${(error as ErrorWithErrno).errno})` : ''}`, LogLevel.WARNING, "☄️")
940
+ });
941
+ downstreamRequest.on('upgrade', (response, downstreamSocket) => {
942
+ const upgradeResponse = `HTTP/${response.httpVersion} ${response.statusCode} ${
943
+ response.statusMessage}\r\n${Object.entries(response.headers)
944
+ .flatMap(([key, value]) => (!Array.isArray(value) ? [value] : value)
945
+ .map(oneValue => [key, oneValue]))
946
+ .map(([key, value]) =>
947
+ `${key}: ${value}\r\n`).join('')}\r\n`;
948
+ upstreamSocket.write(upgradeResponse);
949
+ upstreamSocket.allowHalfOpen = true;
950
+ downstreamSocket.allowHalfOpen = true;
951
+ downstreamSocket.on('data', (data) => upstreamSocket.write(data));
952
+ upstreamSocket.on('data', (data) => downstreamSocket.write(data));
953
+ downstreamSocket.on('error', (error) => {
954
+ log(`downstream socket has errored ${
955
+ (error as ErrorWithErrno).errno ?
956
+ `(${(error as ErrorWithErrno).errno})` : ''}`, LogLevel.WARNING, "☄️")
957
+ })
958
+ upstreamSocket.on('error', (error) => {
959
+ log(`upstream socket has errored ${
960
+ (error as ErrorWithErrno).errno ?
961
+ `(${(error as ErrorWithErrno).errno})` : ''}`, LogLevel.WARNING, "☄️")
962
+ })
963
+ });
964
+ })
872
965
  .listen(config.port);
873
966
  };
874
967
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "local-traffic",
3
- "version": "0.0.31",
3
+ "version": "0.0.34",
4
4
  "main": "index.ts",
5
5
  "private": false,
6
6
  "keywords": [
@@ -8,7 +8,8 @@
8
8
  "proxy",
9
9
  "h2",
10
10
  "http2",
11
- "https"
11
+ "https",
12
+ "websocket"
12
13
  ],
13
14
  "license": "MIT",
14
15
  "repository": "git@github.com:libetl/local-traffic.git",
@@ -16,16 +17,22 @@
16
17
  "scripts": {
17
18
  "start": "./dist/localTraffic.js",
18
19
  "typescript": "tsc",
19
- "terser": "echo '#!/usr/bin/env node\n' \"$(terser ./dist/index.js -c -m --toplevel)\" > ./dist/localTraffic.js",
20
+ "shebang": "echo '#!/usr/bin/env node' | cat - ./dist/terser.js > ./dist/localTraffic.js",
21
+ "terser": "terser ./dist/index.js -c -m --toplevel > ./dist/terser.js",
20
22
  "chmod": "chmod a+x ./dist/localTraffic.js",
21
- "build": "yarn typescript && yarn terser && yarn chmod"
23
+ "clean": "rm -rf dist",
24
+ "build": "npm run clean && npm run typescript && npm run terser && npm run shebang && npm run chmod"
22
25
  },
23
26
  "devDependencies": {
24
- "@types/node": "^17.0.14",
25
- "terser": "^5.10.0",
26
- "typescript": "^4.5.5"
27
+ "@types/node": "^17.0.33",
28
+ "terser": "^5.13.1",
29
+ "typescript": "^4.6.4"
27
30
  },
28
31
  "bin": {
29
32
  "local-traffic": "./dist/localTraffic.js"
33
+ },
34
+ "volta": {
35
+ "node": "18.1.0",
36
+ "npm": "8.10.0"
30
37
  }
31
38
  }