UNPKG

nstdlib-nightly

Version:

Node.js standard library converted to runtime-agnostic ES modules.

310 lines (291 loc) 9.77 kB
// Source: https://github.com/nodejs/node/blob/65eff1eb/lib/internal/modules/esm/fetch_module.js import * as __hoisted_http__ from "nstdlib/lib/http"; import * as __hoisted_dns_promises__ from "nstdlib/lib/dns/promises"; import * as __hoisted_zlib__ from "nstdlib/lib/zlib"; import { Buffer as __Buffer__ } from "nstdlib/lib/buffer"; import { codes as __codes__ } from "nstdlib/lib/internal/errors"; import { URL } from "nstdlib/lib/internal/url"; import * as net from "nstdlib/lib/net"; import { once } from "nstdlib/lib/events"; import { compose } from "nstdlib/lib/stream"; import * as __hoisted_https__ from "nstdlib/lib/https"; const { concat: BufferConcat } = __Buffer__; const { ERR_NETWORK_IMPORT_DISALLOWED, ERR_NETWORK_IMPORT_BAD_RESPONSE, ERR_MODULE_NOT_FOUND, } = __codes__; /** * @typedef CacheEntry * @property {Promise<string> | string} resolvedHREF Parsed HREF of the request. * @property {Record<string, string>} headers HTTP headers of the response. * @property {Promise<Buffer> | Buffer} body Response body. */ /** * Only for GET requests, other requests would need new Map * HTTP cache semantics keep diff caches * * It caches either the promise or the cache entry since import.meta.url needs * the value synchronously for the response location after all redirects. * * Maps HREF to pending cache entry * @type {Map<string, Promise<CacheEntry> | CacheEntry>} */ const cacheForGET = new Map(); // [1] The V8 snapshot doesn't like some C++ APIs to be loaded eagerly. Do it // lazily/at runtime and not top level of an internal module. // [2] Creating a new agent instead of using the gloabl agent improves // performance and precludes the agent becoming tainted. /** @type {import('https').Agent} The Cached HTTP Agent for **secure** HTTP requests. */ let HTTPSAgent; /** * Make a HTTPs GET request (handling agent setup if needed, caching the agent to avoid * redudant instantiations). * @param {Parameters<import('https').get>[0]} input - The URI to fetch. * @param {Parameters<import('https').get>[1]} options - See https.get() options. */ function HTTPSGet(input, options) { const https = __hoisted_https__; // [1] HTTPSAgent ??= new https.Agent({ // [2] keepAlive: true, }); return https.get(input, { agent: HTTPSAgent, ...options, }); } /** @type {import('https').Agent} The Cached HTTP Agent for **insecure** HTTP requests. */ let HTTPAgent; /** * Make a HTTP GET request (handling agent setup if needed, caching the agent to avoid * redudant instantiations). * @param {Parameters<import('http').get>[0]} input - The URI to fetch. * @param {Parameters<import('http').get>[1]} options - See http.get() options. */ function HTTPGet(input, options) { const http = __hoisted_http__; // [1] HTTPAgent ??= new http.Agent({ // [2] keepAlive: true, }); return http.get(input, { agent: HTTPAgent, ...options, }); } /** @type {import('../../dns/promises.js').lookup} */ function dnsLookup(hostname, options) { // eslint-disable-next-line no-func-assign dnsLookup = __hoisted_dns_promises__.lookup; return dnsLookup(hostname, options); } let zlib; /** * Create a decompressor for the Brotli format. * @returns {import('zlib').BrotliDecompress} */ function createBrotliDecompress() { zlib ??= __hoisted_zlib__; // [1] // eslint-disable-next-line no-func-assign createBrotliDecompress = zlib.createBrotliDecompress; return createBrotliDecompress(); } /** * Create an unzip handler. * @returns {import('zlib').Unzip} */ function createUnzip() { zlib ??= __hoisted_zlib__; // [1] // eslint-disable-next-line no-func-assign createUnzip = zlib.createUnzip; return createUnzip(); } /** * Redirection status code as per section 6.4 of RFC 7231: * https://datatracker.ietf.org/doc/html/rfc7231#section-6.4 * and RFC 7238: * https://datatracker.ietf.org/doc/html/rfc7238 * @param {number} statusCode * @returns {boolean} */ function isRedirect(statusCode) { switch (statusCode) { case 300: // Multiple Choices case 301: // Moved Permanently case 302: // Found case 303: // See Other case 307: // Temporary Redirect case 308: // Permanent Redirect return true; default: return false; } } /** * @typedef AcceptMimes possible values of Accept header when fetching a module * @property {Promise<string> | string} default default Accept header value. * @property {Record<string, string>} json Accept header value when fetching module with importAttributes json. * @type {AcceptMimes} */ const acceptMimes = { __proto_: null, default: "*/*", json: "application/json,*/*;charset=utf-8;q=0.5", }; /** * @param {URL} parsed * @returns {Promise<CacheEntry> | CacheEntry} */ function fetchWithRedirects(parsed, context) { const existing = cacheForGET.get(parsed.href); if (existing) { return existing; } const handler = parsed.protocol === "http:" ? HTTPGet : HTTPSGet; const result = (async () => { const accept = acceptMimes[context.importAttributes?.type] ?? acceptMimes.default; const req = handler(parsed, { headers: { Accept: accept }, }); // Note that `once` is used here to handle `error` and that it hits the // `finally` on network error/timeout. const { 0: res } = await once(req, "response"); try { const hasLocation = Object.prototype.hasOwnProperty.call( res.headers, "location", ); if (isRedirect(res.statusCode) && hasLocation) { const location = new URL(res.headers.location, parsed); if (location.protocol !== "http:" && location.protocol !== "https:") { throw new ERR_NETWORK_IMPORT_DISALLOWED( res.headers.location, parsed.href, "cannot redirect to non-network location", ); } const entry = await fetchWithRedirects(location, context); cacheForGET.set(parsed.href, entry); return entry; } if (res.statusCode === 404) { const err = new ERR_MODULE_NOT_FOUND(parsed.href, null, parsed); err.message = `Cannot find module '${parsed.href}', HTTP 404`; throw err; } // This condition catches all unsupported status codes, including // 3xx redirection codes without `Location` HTTP header. if (res.statusCode < 200 || res.statusCode >= 300) { throw new ERR_NETWORK_IMPORT_DISALLOWED( res.headers.location, parsed.href, "cannot redirect to non-network location", ); } const { headers } = res; const contentType = headers["content-type"]; if (!contentType) { throw new ERR_NETWORK_IMPORT_BAD_RESPONSE( parsed.href, "the 'Content-Type' header is required", ); } /** * @type {CacheEntry} */ const entry = { resolvedHREF: parsed.href, headers: { "content-type": res.headers["content-type"], }, body: (async () => { let bodyStream = res; if (res.headers["content-encoding"] === "br") { bodyStream = compose(res, createBrotliDecompress()); } else if ( res.headers["content-encoding"] === "gzip" || res.headers["content-encoding"] === "deflate" ) { bodyStream = compose(res, createUnzip()); } const buffers = await bodyStream.toArray(); const body = BufferConcat(buffers); entry.body = body; return body; })(), }; cacheForGET.set(parsed.href, entry); await entry.body; return entry; } finally { req.destroy(); } })(); cacheForGET.set(parsed.href, result); return result; } const allowList = new net.BlockList(); allowList.addAddress("::1", "ipv6"); allowList.addRange("127.0.0.1", "127.255.255.255"); /** * Returns if an address has local status by if it is going to a local * interface or is an address resolved by DNS to be a local interface * @param {string} hostname url.hostname to test * @returns {Promise<boolean>} */ async function isLocalAddress(hostname) { try { if ( String.prototype.startsWith.call(hostname, "[") && String.prototype.endsWith.call(hostname, "]") ) { hostname = String.prototype.slice.call(hostname, 1, -1); } const addr = await dnsLookup(hostname, { order: "verbatim" }); const ipv = addr.family === 4 ? "ipv4" : "ipv6"; return allowList.check(addr.address, ipv); } catch { // If it errored, the answer is no. } return false; } /** * Fetches a location with a shared cache following redirects. * Does not respect HTTP cache headers. * * This splits the header and body Promises so that things only needing * headers don't need to wait on the body. * * In cases where the request & response have already settled, this returns the * cache value synchronously. * @param {URL} parsed * @param {ESModuleContext} context * @returns {ReturnType<typeof fetchWithRedirects>} */ function fetchModule(parsed, context) { const { parentURL } = context; const { href } = parsed; const existing = cacheForGET.get(href); if (existing) { return existing; } if (parsed.protocol === "http:") { return Promise.prototype.then.call( isLocalAddress(parsed.hostname), (is) => { if (is !== true) { throw new ERR_NETWORK_IMPORT_DISALLOWED( href, parentURL, "http can only be used to load local resources (use https instead).", ); } return fetchWithRedirects(parsed, context); }, ); } return fetchWithRedirects(parsed, context); } export { fetchModule };