UNPKG

@adobe/helix-cli

Version:
458 lines (428 loc) • 14.5 kB
/* * Copyright 2018 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software distributed under * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ import fs from 'fs-extra'; import crypto from 'crypto'; import path from 'path'; import { Socket } from 'net'; import { PassThrough } from 'stream'; import cookie from 'cookie'; import { getFetch } from '../fetch-utils.js'; const utils = { status2level(status, debug3xx) { if (status < 300) { return 'debug'; } if (status < 400) { return debug3xx ? 'debug' : 'info'; } if (status < 500) { return 'warn'; } return 'error'; }, /** * Checks if the file addressed by the given filename exists and is a regular file. * @param {String} filename Path to file * @returns {Promise} Returns promise that resolves with the filename or rejects if is not a file. */ async isFile(filename) { const stats = await fs.stat(filename); if (!stats.isFile()) { throw Error(`no regular file: ${filename}`); } return filename; }, /** * Fetches content from the given uri * @param {String} uri URL to fetch * @param {RequestContext} ctx the context * @param {object} auth authentication object ({@see https://github.com/request/request#http-authentication}) * @returns {Buffer} The requested content or NULL if not exists. */ async fetch(ctx, uri, auth) { const headers = { 'X-Request-Id': ctx.requestId, }; if (auth) { headers.authorization = `Bearer ${auth}`; } const res = await getFetch()(uri, { cache: 'no-store', headers, }); const body = await res.buffer(); if (!res.ok) { const level = utils.status2level(res.status); ctx.log[level](`resource at ${uri} does not exist. got ${res.status} from server`); return null; } return body; }, /** * Injects the live-reload script * @param {string} body the html body * @param {HelixServer} server the proxy server * @returns {string} the modified body */ injectLiveReloadScript(body, server) { let match = body.match(/<\/head>/i); if (!match) { match = body.match(/<\/body>/i); } if (!match) { match = body.match(/<\/html>/i); } // don't inject if no html found at all. if (match) { const { index } = match; // eslint-disable-next-line no-param-reassign let newbody = body.substring(0, index); if (process.env.CODESPACES === 'true') { newbody += `<script> window.LiveReloadOptions = { host: new URL(location.href).hostname.replace(/-[0-9]+\\.preview\\.app\\.github\\.dev/, '-35729.preview.app.github.dev'), port: 443, https: true, }; </script>`; } else { newbody += `<script>window.LiveReloadOptions={port:${server.port},host:location.hostname,https:${server.scheme === 'https'}};</script>`; } newbody += '<script src="/__internal__/livereload.js"></script>'; newbody += body.substring(index); return newbody; } return body; }, /** * Injects meta tags * @param {string} body the html body * @param {object} props meta properties * @returns {string} the modified body */ injectMeta(body, props) { const match = body.match(/<\/head>/i); if (!match) { return body; } const text = Object.entries(props).map(([property, content]) => { const c = content .replace(/&/g, '&amp;') .replace(/"/g, '&quot;'); return `<meta property="${property}" content="${c}">`; }).join('\n'); const { index } = match; return `${body.substring(0, index)}${text}${body.substring(index)}`; }, /** * Computes a path to store a cache objet from a url * @param {string} url the url * @param {string} directory a local directory path * @returns {string} the computed path */ computePathForCache(url, directory) { const u = new URL(url); const { pathname, search } = u; let fileName = pathname.substring(1) || 'index.html'; if (search) { let qs = search.substring(1); // remove leading '?' if (fileName.length + qs.length > 255) { // try with query string as md5 qs = crypto.createHash('md5').update(qs).digest('hex'); } if (fileName.length + qs.length <= 255) { const index = fileName.lastIndexOf('.'); if (index > -1) { // inject qs before extension fileName = `${fileName.substring(0, index)}!${qs}${fileName.substring(index)}`; } else { fileName = `${fileName}!${qs}`; } } else { // still too long, use md5 as filename fileName = crypto.createHash('md5').update(`${fileName}${search.substring(1)}`).digest('hex'); } } return path.resolve(directory, fileName); }, /** * Writes a partial request object to a local file * @param {string} url the request url * @param {string} directory a local directory path * @param {Object} ret the partial request object ({ body, headers, status }) * @param {Logger} logger a logger */ async writeToCache(url, directory, { body, headers, status }, logger) { try { const filePath = utils.computePathForCache(url, directory); const parent = path.dirname(filePath); logger.debug(`Not in cache, saving: ${filePath}`); await fs.ensureDir(parent); await fs.writeFile(filePath, body); await fs.writeJSON(`${filePath}.json`, { headers, status }); } catch (error) { logger.error(error); } }, /** * Returns a partial request object ({ body, headers, status }) from a local file * @param {string} url the request url * @param {string} directory a local directory path * @param {Logger} logger a logger * @returns {Object} the partial request object ({ body, headers, status }). Null if not found. */ async getFromCache(url, directory, logger) { try { const filePath = utils.computePathForCache(url, directory); logger.debug(`Trying from cache first: ${filePath}`); if (await fs.pathExists(filePath)) { const body = await fs.readFile(filePath); const { headers, status } = await fs.readJSON(`${filePath}.json`); return { body, headers, status }; } } catch (error) { logger.error(error); } return null; }, /** * Fetches the content from the url and streams it back to the response. * @param {RequestContext} ctx Context * @param {string} url The url to fetch from * @param {Request} req The original express request * @param {Response} res The express response * @param {object} opts additional request options * @return {Promise} A promise that resolves when the stream is done. */ async proxyRequest(ctx, url, req, res, opts = {}) { ctx.log.debug(`Proxy ${req.method} request to ${url}`); if (opts.cacheDirectory) { const cached = await utils.getFromCache(url, opts.cacheDirectory, ctx.log); if (cached) { res .status(cached.status) .set(cached.headers) .send(cached.body); return; } } let body; // GET and HEAD requests can't have a body if (!['GET', 'HEAD'].includes(req.method)) { body = new PassThrough(); req.pipe(body); } const stream = new PassThrough(); req.pipe(stream); const headers = { 'x-forwarded-host': `localhost:${ctx.config.server.port}`, 'x-forwarded-scheme': 'http', ...req.headers, ...(opts.headers || {}), }; // preserve hlx-auth-token cookie const cookies = cookie.parse(headers.cookie || ''); delete headers.cookie; const hlxAuthToken = cookies['hlx-auth-token']; if (hlxAuthToken) { headers.cookie = new URLSearchParams({ 'hlx-auth-token': hlxAuthToken, }).toString(); } delete headers.connection; delete headers['proxy-connection']; delete headers.host; const ret = await getFetch()(url, { method: req.method, headers, cache: 'no-store', body, redirect: 'manual', }); const contentType = ret.headers.get('content-type') || 'text/plain'; const level = utils.status2level(ret.status, true); ctx.log[level](`Proxy ${req.method} request to ${url}: ${ret.status} (${contentType})`); // because fetch decodes the response, we need to reset content encoding and length const respHeaders = Object.fromEntries(ret.headers.entries()); delete respHeaders['content-encoding']; delete respHeaders['content-length']; delete respHeaders['x-frame-options']; delete respHeaders['content-security-policy']; respHeaders['access-control-allow-origin'] = '*'; respHeaders.via = `${ret.httpVersion ?? '1.0'} ${new URL(url).hostname}`; if (ret.status === 404 && contentType.indexOf('text/html') === 0 && opts.file404html) { ctx.log.debug('serve local 404.html', opts.file404html); let textBody = await fs.readFile(opts.file404html, 'utf-8'); if (opts.injectLiveReload) { textBody = utils.injectLiveReloadScript(textBody, ctx.config.server); } res .status(404) .set(respHeaders) .send(textBody); return; } const isHTML = ret.status === 200 && contentType.indexOf('text/html') === 0; const livereload = isHTML && opts.injectLiveReload; const replaceHead = isHTML && opts.headHtml && opts.headHtml.isModified; const doIndex = isHTML && opts.indexer && url.indexOf('.plain.html') < 0; if (isHTML) { let respBody; let textBody; if (contentType.startsWith('text/')) { textBody = await ret.text(); } else { respBody = await ret.buffer(); } const lines = ['----------------------------->']; if (ctx.log.level === 'silly') { lines.push(`${req.method} ${url}`); Object.entries(headers).forEach(([name, value]) => { lines.push(`${name}: ${value}`); }); lines.push(''); lines.push('<-----------------------------'); lines.push(''); lines.push(`http/${ret.httpVersion} ${ret.status} ${ret.statusText}`); ret.headers.forEach((name, value) => { lines.push(`${name}: ${value}`); }); lines.push(''); if (respBody) { lines.push(`<binary ${respBody.length} bytes>`); } else { lines.push(textBody); } ctx.log.trace(lines.join('\n')); } if (replaceHead) { textBody = await opts.headHtml.replace(textBody); } if (livereload) { textBody = utils.injectLiveReloadScript(textBody, ctx.config.server); } textBody = utils.injectMeta(textBody, { 'hlx:proxyUrl': url, }); if (doIndex) { opts.indexer.onData(url, { body: textBody, headers: respHeaders, }); } if (opts.cacheDirectory) { await utils.writeToCache( url, opts.cacheDirectory, { body: respBody || textBody, headers: respHeaders, status: ret.status, }, ctx.log, ); } res .status(ret.status) .set(respHeaders) .send(respBody || textBody); return; } if (opts.cacheDirectory) { const buffer = await ret.buffer(); await utils.writeToCache( url, opts.cacheDirectory, { body: buffer, headers: respHeaders, status: ret.status, }, ctx.log, ); res .status(ret.status) .set(respHeaders) .send(buffer); return; } res .status(ret.status) .set(respHeaders); ret.body.pipe(res); }, /** * Generates a random string of the given `length` consisting of alpha numerical characters. * if `hex` is {@code true}, the string will only consist of hexadecimal digits. * @param {number}length length of the string. * @param {boolean} hex returns a hex string if {@code true} * @returns {String} a random string. */ randomChars(length, hex = false) { if (length === 0) { return ''; } if (hex) { return crypto.randomBytes(Math.round(length / 2)).toString('hex').substring(0, length); } const str = crypto.randomBytes(length).toString('base64'); return str.substring(0, length); }, /** * Checks if the given port is already in use on any addr. This is used to prevent starting a * server on the same port with an existing socket bound to 0.0.0.0 and SO_REUSEADDR. * @param port @param addr * @return {Promise} that resolves `true` if the port is in use. */ checkPortInUse(port, addr = '0.0.0.0') { return new Promise((resolve, reject) => { let socket; const cleanUp = () => { if (socket) { socket.removeAllListeners('connect'); socket.removeAllListeners('error'); socket.end(); socket.destroy(); socket.unref(); socket = null; } }; socket = new Socket(); socket.once('error', (err) => { if (err.code !== 'ECONNREFUSED') { reject(err); } else { resolve(false); } cleanUp(); }); socket.connect(port, addr, () => { resolve(true); cleanUp(); }); }); }, /** * Rewrites all absolute urls to the proxy host with relative ones. * @param {Buffer} html * @param {string} host * @returns {Buffer} */ rewriteUrl(html, host) { const hostPattern = host.replaceAll('.', '\\.'); let text = html.toString('utf-8'); const re = new RegExp(`(src|href)\\s*=\\s*(["'])${hostPattern}(/.*?)?(['"])`, 'gm'); text = text.replaceAll(re, (match, arg, q1, value, q2) => (`${arg}=${q1}${value || '/'}${q2}`)); return Buffer.from(text, 'utf-8'); }, }; export default Object.freeze(utils);