phn
Version:
a lightweight http client for node and bun, replacement for phin
331 lines (274 loc) • 9.61 kB
JavaScript
const path = require("node:path");
const http = require("node:http");
const https = require("node:https");
const http2 = require("node:http2");
const tls = require("node:tls");
const transformStream = require("node:stream").Transform;
const qs = require("node:querystring");
const zlib = require("node:zlib");
const { URL } = require("node:url");
// shim for zstd, uses fzstd if installed
const createZstdDecompress = zlib.createZstdDecompress || (()=>{
try {
const fzstd = require("fzstd");
return ()=>{
return new transformStream({
transform(chunk, encoding, fn) {
try {
if (!this.zstd) this.zstd = new fzstd.Decompress((ch, end) => {
this.push(ch);
if (end) this.push(null);
});
this.zstd.push(chunk);
fn();
} catch (err) {
fn(err);
};
},
flush() {
this.zstd.push(Buffer.alloc(0), true);
}
});
};
} catch (err) {
return null;
};
})();
// shim for iconv-lite
const iconv = (()=>{
try {
return require("iconv-lite");
} catch (err) {
return null;
};
})();
// find available encodings
const supportedCompression = [
(!!createZstdDecompress && "zstd"),
(!!zlib.createBrotliDecompress && "br"),
(!!zlib.createGunzip && "gzip"),
(!!zlib.createInflate && "deflate")
].filter(Boolean).join(", ");
// helper: alpn request
const alpnCache = {};
async function alpn(url) {
return new Promise((resolve) => {
if (alpnCache[url.origin]) return resolve(alpnCache[url.origin]);
const socket = tls.connect({
host: url.hostname,
port: url.port || 443,
servername: url.hostname,
ALPNProtocols: ["h2", "http/1.1"],
}, () => {
alpnCache[url.origin] = socket.alpnProtocol;
resolve(socket.alpnProtocol);
socket.destroy();
});
});
};
// helper: http2 sessions
const http2Sessions = {};
async function http2Session(url, opts){
if (url.origin in http2Sessions && !http2Sessions[url.origin].destroyed && !http2Sessions[url.origin].closed && !http2Sessions[url.origin].destroying) return http2Sessions[url.origin];
return (http2Sessions[url.origin] = http2.connect(`${url.origin}`, opts));
};
// helper: http(s) sessions
const agents = {};
function httpAgent(p){
if (!agents[p] || agents[p].destroyed) agents[p] = new (p === "http:" ? http : https).Agent({ keepAlive: true });
return agents[p];
};
// clean up sessions on exit
process.on("exit", ()=>{
for (const client of Object.values(http2Sessions)) client.close();
});
// phn
const phn = async (opts, fn)=>{
// callback compat
if (typeof fn === "function") return await phn(opts).then(data=>(fn(null, data))).catch(fn);
if (typeof opts === "string") opts = { url: opts };
if (!("url" in opts) || !opts.url) throw new Error("Missing url option from options for request method.");
this.url = (typeof opts.url === "string") ? new URL(opts.url) : opts.url;
this.method = (opts.method || "get").toUpperCase();
this.data = null;
// maximum buffer size
this.maxBuffer = parseInt(opts.maxBuffer,10) || Infinity;
// max redirects
this.maxRedirects = (typeof opts?.maxRedirects === "number") ? opts.maxRedirects : (typeof opts?.follow === "number") ? opts.follow : 20;
opts.redirected = opts.redirected || 0;
// http2 options
this.http2core = (typeof opts.http2 === "object") ? opts.http2 : {};
// headers
this.headers = {};
if (opts.headers) for (const [k,v] of Object.entries(opts.headers)) this.headers[k.toLowerCase()] = v;
// query
if (opts.query) for (const [k,v] of Object.entries(opts.headers)) this.url.searchParams.append(k,v);
// form
if (opts.form) {
this.data = qs.stringify(opts.form);
this.headers["content-type"] = "application/x-www-form-urlencoded";
};
// data
if (opts.data) {
if (typeof opts.data === "object" && !Buffer.isBuffer(opts.data) && !ArrayBuffer.isView(opts.data)) { // json
this.data = JSON.stringify(opts.data);
this.headers["content-type"] = "application/json";
} else {
this.data = opts.data;
if (!this.headers["content-type"]) this.headers["content-type"] = "application/octet-stream";
}
};
// set content-length
if (this.data && !this.headers["content-length"]) this.headers["content-length"] = Buffer.byteLength(this.data);
// compression, set unless explicitly off
if ((!("compression" in opts) || !!opts.compression) && !this.headers["accept-encoding"]) this.headers["accept-encoding"] = (typeof opts.compression === "string") ? opts.compression : supportedCompression;
// send request
let { transport, req, res, stream, client } = await new Promise(async (resolve, reject)=>{
// assemble options for http1
const options = {
protocol: this.url.protocol,
host: this.url.hostname.replace("[", "").replace("]", ""),
port: this.url.port,
path: this.url.pathname + (this.url.search ?? ""),
method: this.method,
headers: this.headers,
agent: httpAgent(this.url.protocol),
...opts.core,
};
let req;
switch (this.url.protocol) {
case "http:":
req = http.request(options, res=>resolve({ transport: "http", req, res, stream: res }));
break;
case "https:":
// use http2 if module is loaded, http2 not explicitly off and available on host
if (http2 && (!("http2" in opts) || !!opts.http2) && ("h2" === await alpn(this.url))) {
// new http2 session
const client = await http2Session(this.url, this.coreOptions);
// reference socket
client.socket.ref();
req = client.request({ ":method": options.method, ":path": options.path, ...options.headers, ...this.http2core });
req.on("response", (headers) => {
const res = { headers, statusCode: headers[":status"] };
resolve({ transport: "http2", req, res, stream: req, client });
});
} else {
req = https.request(options, res=>{
resolve({ transport: "https", req, res, stream: res })
});
};
break;
default:
return reject(new Error(`Bad protocol: ${this.url.protocol}`));
break;
};
// handle timeout
if (opts.timeout) req.setTimeout(opts.timeout);
req.on("timeout", ()=>{
reject(new Error("Timeout reached"));
req.abort?.();
});
// handle error
req.on("error", reject);
// send data
if (this.data) req.write(this.data);
// end request
req.end();
});
// handle aborts
stream.on("aborted", ()=>reject(new Error("Server aborted request")));
// follow redirects
if (res.headers?.location && (opts.follow || opts.followRedirects)) {
// limit the number of redirects
if (this.maxRedirects && ++opts.redirected > this.maxRedirects) throw new Error("Exceeded the maximum number of redirects");
const redirectedUrl = new URL(res.headers["location"], this.url);
if (redirectedUrl.protocol === this.url.protocol && redirectedUrl.host === this.url.host) { // keep cookies
if (res.headers["set-cookie"]) opts.headers = { ...opts.headers, cookie: res.headers["set-cookie"] };
} else { // remove spicy request headers
opts.headers = Object.entries({ ...opts.headers }).reduce((h,[k,v])=>{
if (!["authorization","cookie","proxy-authorization"].includes(k.toLowerCase())) h[k] = v;
return h;
},{});
};
opts.url = redirectedUrl.toString();
return phn(opts, fn);
};
// check content-length header against maxBuffer
if (res.headers["content-length"] && parseInt(res.headers["content-length"],10) > this.maxBuffer) {
throw new Error(`Content length exceeds maxBuffer: ${res.headers["content-length"]}b`);
};
// decompress
switch (res.headers["content-encoding"]) {
case "zstd":
stream = stream.pipe(createZstdDecompress());
break;
case "br":
stream = stream.pipe(zlib.createBrotliDecompress());
break;
case "gzip":
stream = stream.pipe(zlib.createGunzip());
break;
case "deflate":
stream = stream.pipe(zlib.createInflate());
break;
};
// iconv decode via iconv-lite if available
if (iconv && opts.decode) { // iconv.encodingExists("us-ascii")
const charset = (typeof opts.decode === "string") ? opts.decode : res.headers?.['content-type']?.match(/charset=([^;]+)/i)?.[1].trim();
if (charset) {
if (!iconv.encodingExists(charset)) throw new Error(`Unknown Charset ${charset}`);
stream = stream.pipe(iconv.decodeStream(charset)).pipe(new transformStream({
transform(c, _, f) { f(null, Buffer.from(c)); }
}));
};
};
// deliver stream if requested
if (opts.stream) {
client?.unref?.();
return { ...res, req, transport, stream, statusCode: res.statusCode, headers: res.headers };
};
// assemble body
let body = await new Promise((resolve,reject)=>{
let b = Buffer.alloc(0);
stream.on("error", err=>reject(err));
stream.on("data", chunk=>{
b = Buffer.concat([b, chunk]);
if (b.length > opts.maxBuffer) {
reject(new Error(`Content length exceeds maxBuffer: ${res.headers["content-length"]}b`));
stream.destroy();
};
});
stream.on("end", ()=>{
client?.unref?.();
resolve(b);
});
});
// parse body
switch (typeof opts.parse) {
case "string":
switch (opts.parse) {
case "string":
body = body.toString()
break;
case "json":
body = (res.statusCode === 204) ? null : JSON.parse(body);
break;
};
break;
case "function":
body = opts.parse(body);
break;
};
// deliver
return { ...res, req, transport, body, statusCode: res.statusCode, headers: res.headers };
};
// defaults
phn.defaults = (defaults)=>(opts,fn)=>{
if (typeof opts === "string") opts = { url: opts };
for (const k of Object.keys(defaults)) if (!(k in opts)) opts[k] = defaults[k];
return phn(opts,fn);
};
// compat
phn.promisified = phn;
phn.unpromisified = phn;
module.exports = phn;