diff --git a/REFERENCE.md b/REFERENCE.md index 0fc9e1bea..05adcc037 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -289,6 +289,7 @@ The agent filter code objects (functions or objects/classes) based on a format c * `socket "unix" | "net"` Defines the socket implementation to use: [`posix-socket`](https://www.npmjs.com/package/posix-socket) or [`net.Socket`](https://nodejs.org/api/net.html#class-netsocket). The `posix-socket` module provide synchronous methods which avoid creating asynchronous resources that are observed when `ordering` is `"causal"`. To avoid infinite loop, the `"net"` implementation buffers messages. The `"unix"` is probably the better option but it requires node-gyp compilation and is not available on windows. If `"unix"` is chosen but the `posix-socket` could not be installed, the agent will fallback on `"net"`. Default: `"unix"`. * `heartbeat | null` Defines the interval in millisecond where the socket should be flushed. This only has effect if `socket` is `"net"`. Default: `1000`. * `threshold | null` Defines the maximum number of message before the socket should be flushed. This only has effect if `socket` is `"net"`. Default: `100`. +* `proxy-port | null` Defines where the proxy should be listening to intercept http traffic and record html applications running on the browser. Use `0` for a random port. *Default*: `null` which does not deploy the proxy. * `trace-port | ` Defines the communication port between frontend and backend. A string indicates a path to a unix domain socket which is faster. *Default*: `0` which will use a random available port. * `track-port | `: Port in the backend process for serving remote recording HTTP requests. *Default*: `0` A random port will be used. * `intercept-track-port `: Regular expression to whitelist the ports in the frontend process for intercepting remote recording HTTP requests. *Default*: `"^"` Every detected HTTP ports will be spied upon. diff --git a/component/build-prod.mjs b/component/build-prod.mjs index 61b50b609..d449b853e 100644 --- a/component/build-prod.mjs +++ b/component/build-prod.mjs @@ -12,7 +12,12 @@ for (const [name, component, env, resolution] of [ }, ], [null, "error", "node", {}], - [null, "server", "node", { validate: "ajv", source: "default" }], + [ + null, + "server", + "node", + { validate: "ajv", source: "default", instrumentation: "default" }, + ], [null, "client", "node", { validate: "stub" }], [null, "init", "node", {}], [null, "status", "node", {}], diff --git a/components/.ordering b/components/.ordering index 6e3aa9779..097a4ec94 100644 --- a/components/.ordering +++ b/components/.ordering @@ -64,6 +64,7 @@ recorder-standalone receptor command client +mitm server questionnaire setup diff --git a/components/configuration/default/index.mjs b/components/configuration/default/index.mjs index 9bc542a9c..3502e2277 100644 --- a/components/configuration/default/index.mjs +++ b/components/configuration/default/index.mjs @@ -307,6 +307,10 @@ const fields = { extend: overwrite, normalize: identity, }, + "proxy-port": { + extend: overwrite, + normalize: identity, + }, "trace-port": { extend: overwrite, normalize: normalizePort, @@ -478,6 +482,7 @@ export const createConfiguration = (home) => ({ host: "localhost", session: null, sessions: "^", + "proxy-port": null, "trace-port": 0, // possibly overwritten by the agent "http-switch": "__APPMAP__", "trace-protocol": "TCP", diff --git a/components/mitm/default/.env b/components/mitm/default/.env new file mode 100644 index 000000000..eb0faad4f --- /dev/null +++ b/components/mitm/default/.env @@ -0,0 +1,2 @@ +test +node diff --git a/components/mitm/default/.ordering b/components/mitm/default/.ordering new file mode 100644 index 000000000..e0d5bfc63 --- /dev/null +++ b/components/mitm/default/.ordering @@ -0,0 +1,9 @@ +util.mjs +stream.mjs +html.mjs +js.mjs +forward.mjs +intercept.mjs +forge.mjs +proxy.mjs +index.mjs diff --git a/components/mitm/default/forward.mjs b/components/mitm/default/forward.mjs new file mode 100644 index 000000000..f94ae5ae2 --- /dev/null +++ b/components/mitm/default/forward.mjs @@ -0,0 +1,100 @@ +import { Buffer } from "node:buffer"; +import { Socket as NetSocket } from "node:net"; +import { request as requestHttp } from "node:http"; +import { logWarning } from "../../log/index.mjs"; + +const { from: toBuffer } = Buffer; + +export const forwardRequest = (host, inc_req, callback) => { + let done = false; + /* c8 ignore start */ + inc_req.on("error", (error) => { + if (!done) { + done = true; + callback(error, null); + } + }); + /* c8 ignore stop */ + const out_req = requestHttp({ + host: host.name, + port: host.port, + method: inc_req.method, + path: inc_req.url, + headers: inc_req.headers, + }); + /* c8 ignore start */ + out_req.on("error", (error) => { + if (!done) { + done = true; + callback(error, null); + } + }); + /* c8 ignore stop */ + out_req.on("response", (res) => { + if (!done) { + done = true; + callback(null, res); + } + }); + inc_req.pipe(out_req); +}; + +export const forwardResponse = (inc_res, out_res) => { + out_res.writeHead(inc_res.statusCode, inc_res.statusMessage, inc_res.headers); + inc_res.pipe(out_res); +}; + +const stringifyHead = ({ + method, + url, + httpVersion: version, + rawHeaders: headers, +}) => { + const chunks = []; + chunks.push(`${method} ${url} HTTP/${version}`); + for (let index = 0; index < headers.length; index += 2) { + chunks.push(`${headers[index]}: ${headers[index + 1]}`); + } + chunks.push(""); + chunks.push(""); + return chunks.join("\r\n"); +}; + +const forwardSpecial = (host, req, socket, head) => { + const forward_socket = new NetSocket(); + /* c8 ignore start */ + socket.on("error", (error) => { + logWarning( + "error on socket %j >> %s %s %j >> %o", + host, + req.method, + req.url, + req.headers, + error, + ); + forward_socket.destroy(); + }); + forward_socket.on("error", (error) => { + logWarning( + "error on forward socket %j >> %s %s %j >> %o", + host, + req.method, + req.url, + req.headers, + error, + ); + socket.destroy(); + }); + /* c8 ignore stop */ + forward_socket.connect(host.port, host.name); + forward_socket.on("connect", () => { + forward_socket.write(toBuffer(stringifyHead(req), "utf8")); + forward_socket.write(head); + socket.pipe(forward_socket); + forward_socket.pipe(socket); + }); +}; + +export const forwardUpgrade = forwardSpecial; + +export const forwardConnect = forwardSpecial; diff --git a/components/mitm/default/forward.test.mjs b/components/mitm/default/forward.test.mjs new file mode 100644 index 000000000..2828a3a92 --- /dev/null +++ b/components/mitm/default/forward.test.mjs @@ -0,0 +1,173 @@ +import { + createServer as createHttpServer, + request as requestHttp, +} from "node:http"; +import { Buffer } from "node:buffer"; +import { default as WebSocket, WebSocketServer } from "ws"; +import { assertEqual, assertDeepEqual } from "../../__fixture__.mjs"; +import { bufferReadable } from "./stream.mjs"; +import { + forwardRequest, + forwardResponse, + forwardUpgrade, + forwardConnect, +} from "./forward.mjs"; + +const { + String, + Promise, + JSON: { stringify: stringifyJSON, parse: parseJSON }, +} = globalThis; + +const { from: toBuffer } = Buffer; + +const wss = new WebSocketServer({ noServer: true }); + +////////////////// +// Setup Server // +////////////////// + +const server = createHttpServer(); + +server.on("request", (req, res) => { + bufferReadable(req, (buffer) => { + const body = toBuffer( + stringifyJSON({ + method: req.method, + path: req.url, + version: req.httpVersion, + body: buffer.toString("utf8"), + }), + "utf8", + ); + res.writeHead(200, { + "content-type": "application/json", + "content-length": body.length, + }); + res.end(body); + }); +}); + +server.on("upgrade", (req, socket, head) => { + wss.handleUpgrade(req, socket, head, (ws) => { + ws.on("message", (buffer) => { + ws.send(buffer); + }); + }); +}); + +server.listen(0); + +await new Promise((resolve, reject) => { + server.on("listening", resolve); + server.on("error", reject); +}); + +const host = { name: "localhost", port: server.address().port }; + +///////////////// +// Setup Forge // +///////////////// + +const forge = createHttpServer(); + +forge.on("request", (inc_req, out_res) => { + forwardRequest(host, inc_req, (error, inc_res) => { + assertEqual(error, null); + forwardResponse(inc_res, out_res); + }); +}); + +forge.on("upgrade", (req, socket, head) => { + forwardUpgrade(host, req, socket, head); +}); + +forge.on("connect", (req, socket, head) => { + forwardConnect(host, req, socket, head); +}); + +forge.listen(0); + +await new Promise((resolve, reject) => { + forge.on("listening", resolve); + forge.on("error", reject); +}); + +///////////// +// Request // +///////////// + +{ + const req = requestHttp({ + host: "localhost", + port: forge.address().port, + method: "POST", + path: "/path", + version: "1.1", + }); + req.end("body"); + const res = await new Promise((resolve, reject) => { + req.on("response", resolve); + req.on("error", reject); + }); + assertDeepEqual( + parseJSON( + ( + await new Promise((resolve) => { + bufferReadable(res, resolve); + }) + ).toString("utf8"), + ), + { + method: "POST", + path: "/path", + version: "1.1", + body: "body", + }, + ); +} + +///////////// +// Upgrade // +///////////// + +{ + const ws = new WebSocket(`ws://localhost:${String(forge.address().port)}`); + await new Promise((resolve, reject) => { + ws.on("open", resolve); + ws.on("error", reject); + }); + ws.send(toBuffer("message", "utf8")); + assertEqual( + ( + await new Promise((resolve, reject) => { + ws.on("message", resolve); + ws.on("error", reject); + }) + ).toString("utf8"), + "message", + ); + ws.close(); + await new Promise((resolve, reject) => { + ws.on("close", resolve); + ws.on("error", reject); + }); +} + +////////////// +// Teardown // +////////////// + +forge.close(); + +await new Promise((resolve, reject) => { + forge.on("error", reject); + forge.on("close", resolve); +}); + +server.close(); + +await new Promise((resolve, reject) => { + server.on("error", reject); + server.on("close", resolve); +}); diff --git a/components/mitm/default/html.mjs b/components/mitm/default/html.mjs new file mode 100644 index 000000000..5ba42d28a --- /dev/null +++ b/components/mitm/default/html.mjs @@ -0,0 +1,170 @@ +import * as HtmlParser2 from "htmlparser2"; +import { assert, hasOwnProperty } from "../../util/index.mjs"; +import { URL } from "../../url/index.mjs"; +import { logWarning } from "../../log/index.mjs"; +import { InternalAppmapError } from "../../error/index.mjs"; + +const { + String, + Reflect: { ownKeys }, + JSON: { stringify: stringifyJSON }, +} = globalThis; + +const { Parser: HtmlParser } = HtmlParser2; + +const escape_sequence_mapping = { + __proto__: null, + "&": "&", + "<": "<", + ">": ">", + '"': """, +}; + +const escapeSpecialCharacter = (character) => { + assert( + hasOwnProperty(escape_sequence_mapping, character), + "unexpected special character", + InternalAppmapError, + ); + return escape_sequence_mapping[character]; +}; + +const escapeHtml = (string) => + string.replace(/[&<>"]/gu, escapeSpecialCharacter); + +const escapeScriptTag = () => "<\\/script>"; + +const escapeJs = (string) => string.replace(/<\/script>/gu, escapeScriptTag); + +const makeHashUrl = (url, { start, end }) => { + const url_object = new URL(url); + url_object.hash = `#${String(start)}-${String(end)}`; + return url_object.href; +}; + +const extractScriptType = (attributes) => + hasOwnProperty(attributes, "type") ? attributes.type : "script"; + +const isInstrumentable = ({ external, type }) => + !external && (type === "script" || type === "module"); + +// It would be easier to consistently +// set the `type` attribute but jsdom +// does support it and ignore the +// entire script when `type` is present. +const toTypeAttribute = (type) => { + if (type === "script") { + return ""; + } else if (type === "module") { + return ' type="module"'; + } /* c8 ignore start */ else { + throw InternalAppmapError("invalid script type"); + } /* c8 ignore stop */ +}; + +export const instrumentHtml = (instrumentJs, prelude, { url, content }) => { + const tokens = []; + let has_head = false; + let script = null; + const parser = new HtmlParser({ + onopentag: (name, attributes) => { + tokens.push(`<${escapeHtml(name)}`); + for (const key of ownKeys(attributes)) { + tokens.push(` ${escapeHtml(key)}="${escapeHtml(attributes[key])}"`); + } + tokens.push(">"); + if (name === "head") { + if (has_head) { + logWarning("Duplicate head tag in %j", url); + } else { + has_head = true; + for (const { type, url, content } of prelude) { + if (content === null) { + assert( + url !== null, + "prelude file should either define a url or a content", + InternalAppmapError, + ); + tokens.push( + ``, + ); + } else { + tokens.push( + `${escapeJs(content)}`, + ); + } + } + } + } else if (name === "script") { + assert( + script === null, + "nested script tag in html", + InternalAppmapError, + ); + if (has_head) { + script = { + type: extractScriptType(attributes), + external: hasOwnProperty(attributes, "src"), + start: parser.startIndex, + tokens: [], + end: null, + }; + } else { + logWarning( + "Not instrumenting script in %j because it appears before any head html tag", + url, + ); + } + } + }, + onprocessinginstruction: (_name, text) => { + tokens.push(`<${escapeHtml(text)}>`); + }, + oncomment: (comment) => { + tokens.push(``); + }, + ontext: (text) => { + if (script === null) { + tokens.push(escapeHtml(text)); + } else { + script.tokens.push(text); + } + }, + onclosetag: (name) => { + if (script !== null) { + assert( + name === "script", + "expected script closing tag", + InternalAppmapError, + ); + script.end = parser.endIndex; + tokens.push( + isInstrumentable(script) + ? escapeJs( + instrumentJs({ + type: script.type, + url: makeHashUrl(url, script), + content: script.tokens.join(""), + }), + ) + : // We do not need to escape the script content + // because it is impossible for it to contain + // `` as it would have terminated + // the tag. + script.tokens.join(""), + ); + script = null; + } + tokens.push(``); + }, + }); + parser.end(content); + assert( + script === null, + "unfinished script tag should have been completed by htmlparser2", + InternalAppmapError, + ); + return tokens.join(""); +}; diff --git a/components/mitm/default/html.test.mjs b/components/mitm/default/html.test.mjs new file mode 100644 index 000000000..482776ecb --- /dev/null +++ b/components/mitm/default/html.test.mjs @@ -0,0 +1,244 @@ +import { assertEqual } from "../../__fixture__.mjs"; +import { instrumentHtml } from "./html.mjs"; + +const { + Infinity, + Error, + JSON: { stringify: stringifyJSON }, +} = globalThis; + +const dead = () => { + throw new Error("dead"); +}; + +// prelude // +assertEqual( + instrumentHtml( + dead, + [ + { type: "script", url: null, content: "/* prelude1 */" }, + { type: "module", url: "http://host/prelude2.js", content: null }, + ], + { + url: "protocol://host/path", + content: [ + "", + ["", ""], + ["", ""], + "", + ] + .flat(Infinity) + .join(""), + }, + ), + [ + "", + [ + "", + [""], + ['"], + "", + ], + ["", ""], + "", + ] + .flat(Infinity) + .join(""), +); + +// attribute // +assertEqual( + instrumentHtml(dead, [], { + url: "protocol://host/path", + content: [ + '', + ["", ""], + "", + ] + .flat(Infinity) + .join(""), + }), + ['', ["", ""], ""] + .flat(Infinity) + .join(""), +); + +// processing instruction // +assertEqual( + instrumentHtml(dead, [], { + url: "protocol://host/path", + content: ["", "", ["", ""], ""] + .flat(Infinity) + .join(""), + }), + ["", "", ["", ""], ""] + .flat(Infinity) + .join(""), +); + +// comment // +assertEqual( + instrumentHtml(dead, [], { + url: "protocol://host/path", + content: ["", "", ["", ""], ""] + .flat(Infinity) + .join(""), + }), + ["", "", ["", ""], ""] + .flat(Infinity) + .join(""), +); + +// text // +assertEqual( + instrumentHtml(dead, [], { + url: "protocol://host/path", + content: ["", ">text<", ["", ""], ""] + .flat(Infinity) + .join(""), + }), + ["", ">text<", ["", [], ""], ""] + .flat(Infinity) + .join(""), +); + +// script >> external // +assertEqual( + instrumentHtml(dead, [], { + url: "protocol://host/path", + content: [ + "", + [ + "", + ['"], + "", + ], + "", + ] + .flat(Infinity) + .join(""), + }), + [ + "", + [ + "", + ['"], + "", + ], + "", + ] + .flat(Infinity) + .join(""), +); + +// script >> alternative type // +assertEqual( + instrumentHtml(dead, [], { + url: "protocol://host/path", + content: [ + "", + [ + "", + [""], + "", + ], + "", + ] + .flat(Infinity) + .join(""), + }), + [ + "", + [ + "", + ['"], + "", + ], + "", + ] + .flat(Infinity) + .join(""), +); + +// script >> default // +assertEqual( + instrumentHtml( + ({ type, url, content }) => + `${stringifyJSON([type, url, content, ""])};`, + [], + { + url: "protocol://host/path", + content: [ + "", + ["", [""], ""], + "", + ] + .flat(Infinity) + .join(""), + }, + ), + [ + "", + [ + "", + [ + "", + ], + "", + ], + "", + ] + .flat(Infinity) + .join(""), +); + +// script >> module // +assertEqual( + instrumentHtml(({ type }) => `/* ${type} */`, [], { + url: "protocol://host/path", + content: [ + "", + ["", [""], ""], + "", + ] + .flat(Infinity) + .join(""), + }), + [ + "", + [ + "", + ['"], + "", + ], + "", + ] + .flat(Infinity) + .join(""), +); + +// script >> before head // +assertEqual( + instrumentHtml(dead, [], { + url: "protocol://host/path", + content: ["", [""], ""] + .flat(Infinity) + .join(""), + }), + ["", [""], ""] + .flat(Infinity) + .join(""), +); + +// script >> unfinished // +assertEqual( + instrumentHtml(dead, [], { + url: "protocol://host/path", + content: ["", [""], ""] + .flat(Infinity) + .join(""), +); diff --git a/components/mitm/default/index.mjs b/components/mitm/default/index.mjs new file mode 100644 index 000000000..72fb62f07 --- /dev/null +++ b/components/mitm/default/index.mjs @@ -0,0 +1,63 @@ +import { createServer as createHttpServer } from "node:http"; +import { WebSocketServer } from "ws"; +import { createPool, addPool, closePool } from "../../pool/index.mjs"; +import { assert } from "../../util/index.mjs"; +import { InternalAppmapError } from "../../error/index.mjs"; +import { + partialx_, + partialx__, + partialx___, + partialxx___, + partialxx____, + partialxxx___, + partialxxx____, +} from "./util.mjs"; +import { + interceptRequest, + interceptConnect, + interceptUpgrade, +} from "./intercept.mjs"; +import { requestProxy, upgradeProxy, connectProxy } from "./proxy.mjs"; + +const { Promise, Map } = globalThis; + +export const openMitmAsync = async (configuration, backend) => { + assert( + configuration["proxy-port"] !== null, + "cannot open mitm because it is disabled", + InternalAppmapError, + ); + const wss = new WebSocketServer({ noServer: true }); + const proxy = createHttpServer(); + const handlers = { + request: partialxx___(interceptRequest, configuration, backend), + connect: partialxx____(interceptConnect, configuration, backend), + upgrade: partialxxx____(interceptUpgrade, configuration, backend, wss), + }; + const pool = createPool(); + const servers = new Map(); + proxy.on("connection", partialx_(addPool, pool)); + proxy.on("request", partialx__(requestProxy, handlers.request)); + proxy.on("upgrade", partialx___(upgradeProxy, handlers.upgrade)); + proxy.on("connect", partialxxx___(connectProxy, handlers, servers, pool)); + proxy.listen(configuration["proxy-port"]); + await new Promise((resolve, reject) => { + proxy.on("error", reject); + proxy.on("listening", resolve); + }); + return { proxy, servers, pool }; +}; + +export const getMitmPort = ({ proxy }) => proxy.address().port; + +export const closeMitmAsync = async ({ proxy, servers, pool }) => { + const promises = [proxy, ...servers.values()].map((server) => { + server.close(); + return new Promise((resolve, reject) => { + server.on("error", reject); + server.on("close", resolve); + }); + }); + closePool(pool, 1000); + await Promise.all(promises); +}; diff --git a/components/mitm/default/index.test.mjs b/components/mitm/default/index.test.mjs new file mode 100644 index 000000000..29d8d6432 --- /dev/null +++ b/components/mitm/default/index.test.mjs @@ -0,0 +1,125 @@ +import { + createServer as createHttpServer, + request as requestHttp, +} from "node:http"; +import { assertEqual } from "../../__fixture__.mjs"; +import { createBackend } from "../../backend/index.mjs"; +import { + createConfiguration, + extendConfiguration, +} from "../../configuration/index.mjs"; +import { openMitmAsync, getMitmPort, closeMitmAsync } from "./index.mjs"; + +const { Error, Promise } = globalThis; + +/////////// +// Setup // +/////////// + +const server = createHttpServer(); + +server.listen(0); + +await new Promise((resolve, reject) => { + server.on("error", reject); + server.on("listening", resolve); +}); + +server.on("request", (_req, res) => { + res.writeHead(200); + res.end(); +}); + +const configuration = extendConfiguration( + createConfiguration("protocol://host/path"), + { + "proxy-port": 0, + }, + null, +); + +const mitm = await openMitmAsync(configuration, createBackend(configuration)); + +//////////// +// Tunnel // +//////////// + +const con_req = requestHttp({ + host: "localhost", + port: getMitmPort(mitm), + method: "CONNECT", + path: `localhost:${server.address().port}`, + version: "1.1", +}); + +con_req.end(); + +const [con_res, socket, _head] = await new Promise((resolve, reject) => { + con_req.on("response", (_con_res) => { + reject(new Error("regular response to connect request")); + }); + con_req.on("connect", (res, socket, head) => { + resolve([res, socket, head]); + }); + con_req.on("error", reject); +}); + +assertEqual(con_res.statusCode, 200); + +assertEqual(con_res.statusMessage, "Connection Established"); + +await new Promise((resolve, reject) => { + con_res.on("end", resolve); + con_res.on("data", () => { + reject(new Error("unexpected data")); + }); + con_res.on("error", reject); +}); + +///////////// +// Request // +///////////// + +const get_req = requestHttp({ + createConnection: (_options, _callback) => socket, + method: "GET", + path: "/", + version: "1.1", +}); + +get_req.end(); + +const get_res = await new Promise((resolve, reject) => { + get_req.on("response", resolve); + get_req.on("error", reject); +}); + +assertEqual(get_res.statusCode, 200); + +await new Promise((resolve, reject) => { + get_res.on("end", resolve); + get_res.on("data", () => { + reject(new Error("unexpected data")); + }); + get_res.on("error", reject); +}); + +socket.end(); + +await new Promise((resolve, reject) => { + socket.on("error", reject); + socket.on("close", resolve); +}); + +////////////// +// Teardown // +////////////// + +await closeMitmAsync(mitm); + +server.close(); + +await new Promise((resolve, reject) => { + server.on("error", reject); + server.on("close", resolve); +}); diff --git a/components/mitm/default/intercept.mjs b/components/mitm/default/intercept.mjs new file mode 100644 index 000000000..152c7e1a5 --- /dev/null +++ b/components/mitm/default/intercept.mjs @@ -0,0 +1,248 @@ +import { readFile } from "node:fs"; +import { Buffer } from "node:buffer"; +import { + logDebugWhen, + logErrorWhen, + logWarning, + logDebug, +} from "../../log/index.mjs"; +import { self_directory } from "../../self/index.mjs"; +import { URL } from "../../url/index.mjs"; +import { InternalAppmapError } from "../../error/index.mjs"; +import { assert, hasOwnProperty } from "../../util/index.mjs"; +import { sendBackend } from "../../backend/index.mjs"; +import { partialxx_, resolveHostPath } from "./util.mjs"; +import { bufferReadable } from "./stream.mjs"; +import { instrumentHtml } from "./html.mjs"; +import { instrumentJs } from "./js.mjs"; +import { + forwardRequest, + forwardResponse, + forwardUpgrade, + forwardConnect, +} from "./forward.mjs"; + +const { + JSON: { stringify: stringifyJSON, parse: parseJSON }, +} = globalThis; + +const { from: toBuffer } = Buffer; + +// https://www.ietf.org/rfc/rfc9239.html#section-6 +// https://www.iana.org/assignments/media-types/media-types.xhtml +const isJavascriptContentType = (header) => + header.startsWith("text/javascript") || + header.startsWith("text/ecmascript") || + header.startsWith("application/javascript") || + header.startsWith("application/ecmascript"); + +const isHtmlContentType = (header) => header.startsWith("text/html"); + +const getContentTypeCharset = (header) => { + logDebugWhen( + !header.toLowerCase().includes("charset=utf-8"), + "http response content-type header does not declare utf8 encoding but will try to use it anyway, got: %j", + header, + ); + return "utf8"; +}; + +export const interceptRequest = ( + configuration, + backend, + host, + inc_req, + out_res, +) => { + logDebug( + "intercept request to %j >> %s %s %j", + host, + inc_req.method, + inc_req.url, + inc_req.headers, + ); + assert( + inc_req.url !== `/${configuration["http-switch"]}`, + "unexpected regular request related to appmap", + InternalAppmapError, + ); + forwardRequest(host, inc_req, (error, inc_res) => { + /* c8 ignore start */ if (error !== null) { + logWarning( + "error on request to %j >> %s %s %j >> %o", + host, + inc_req.method, + inc_req.url, + inc_req.headers, + error, + ); + out_res.writeHead(500); + out_res.end(); + } /* c8 ignore stop */ else { + logDebug( + "intercept response from %j >> %j %s %j", + host, + inc_res.statusCode, + inc_res.statusMessage, + inc_res.headers, + ); + if ( + hasOwnProperty(inc_res.headers, "content-type") && + isHtmlContentType(inc_res.headers["content-type"]) + ) { + readFile( + new URL("dist/bundles/recorder-browser.mjs", self_directory), + "utf8", + (error, content) => { + assert( + !logErrorWhen( + error !== null, + "could not read recorder bundle for browser >> %o", + error, + ), + "could not read recorder bundle for browser", + InternalAppmapError, + ); + bufferReadable(inc_res, (buffer) => { + const encoding = getContentTypeCharset( + inc_res.headers["content-type"], + ); + const body = toBuffer( + instrumentHtml( + partialxx_(instrumentJs, configuration, backend), + [ + { + type: "script", + url: null, + content: ` + "use strict"; + ((() => { + if (globalThis.__APPMAP_CONFIGURATION__ === void 0) { + globalThis.__APPMAP_CONFIGURATION__ = ${stringifyJSON( + configuration, + )}; + globalThis.__APPMAP_LOG_LEVEL__ = ${stringifyJSON( + configuration.log.level, + )}; + ${content} + } + }) ()); + `, + }, + ], + { + url: resolveHostPath(host, inc_req.url), + content: buffer.toString(encoding), + }, + ), + encoding, + ); + out_res.writeHead(inc_res.statusCode, inc_res.statusMessage, { + ...inc_res.headers, + "content-length": body.length, + }); + out_res.end(body); + }); + }, + ); + } else if ( + hasOwnProperty(inc_res.headers, "content-type") && + isJavascriptContentType(inc_res.headers["content-type"]) + ) { + bufferReadable(inc_res, (buffer) => { + const encoding = getContentTypeCharset( + inc_res.headers["content-type"], + ); + const body = toBuffer( + instrumentJs(configuration, backend, { + url: resolveHostPath(host, inc_req.url), + content: buffer.toString(encoding), + }), + encoding, + ); + out_res.writeHead(inc_res.statusCode, inc_res.statusMessage, { + ...inc_res.headers, + "content-length": body.length, + }); + out_res.end(body); + }); + } else { + forwardResponse(inc_res, out_res); + } + } + }); +}; + +export const interceptUpgrade = ( + configuration, + backend, + wss, + host, + req, + socket, + head, +) => { + logDebug( + "intercept upgrade to %j >> %s %s %j", + host, + req.method, + req.url, + req.headers, + ); + if (req.url === `/${configuration["http-switch"]}`) { + /* c8 ignore start */ + socket.on("error", (error) => { + logWarning( + "appmap socket error %j >> %s %s %j >> %o", + host, + req.method, + req.url, + req.headers, + error, + ); + }); + /* c8 ignore stop */ + wss.handleUpgrade(req, socket, head, (ws) => { + ws.on("message", (data) => { + sendBackend(backend, parseJSON(data.toString("utf8"))); + }); + /* c8 ignore start */ + ws.on("error", (error) => { + logWarning( + "appmap websocket error %j >> %s %s %j >> %o", + host, + req.method, + req.url, + req.headers, + error, + ); + }); + }); + /* c8 ignore stop */ + } else { + forwardUpgrade(host, req, socket, head); + } +}; + +export const interceptConnect = ( + configuration, + _backend, + host, + req, + socket, + head, +) => { + logDebug( + "intercept connect to %j >> %s %s %j", + host, + req.method, + req.url, + req.headers, + ); + assert( + req.url !== `/${configuration["http-switch"]}`, + "unexpected connect request related to appmap", + InternalAppmapError, + ); + forwardConnect(host, req, socket, head); +}; diff --git a/components/mitm/default/intercept.test.mjs b/components/mitm/default/intercept.test.mjs new file mode 100644 index 000000000..b4d0c7a75 --- /dev/null +++ b/components/mitm/default/intercept.test.mjs @@ -0,0 +1,317 @@ +import { + createServer as createHttpServer, + request as requestHttp, +} from "node:http"; +import { Buffer } from "node:buffer"; +import { default as WebSocket, WebSocketServer } from "ws"; +import { assertEqual, assertMatch } from "../../__fixture__.mjs"; +import { + createConfiguration, + extendConfiguration, +} from "../../configuration/index.mjs"; +import { createBackend } from "../../backend/index.mjs"; +import { bufferReadable } from "./stream.mjs"; +import { + interceptRequest, + interceptUpgrade, + interceptConnect, +} from "./intercept.mjs"; + +const { + Error, + String, + Promise, + JSON: { stringify: stringifyJSON }, +} = globalThis; + +const { from: toBuffer } = Buffer; + +const wss = new WebSocketServer({ noServer: true }); + +const respond = (res, type, content) => { + const body = toBuffer(content, "utf8"); + res.writeHead(200, { + "content-type": `${type}; charset=utf-8`, + "content-length": body.length, + }); + res.end(body); +}; + +////////////////// +// Setup Server // +////////////////// + +const server = createHttpServer(); + +server.on("request", (req, res) => { + assertEqual(req.method, "GET"); + if (req.url === "/index.html") { + respond( + res, + "text/html", + ` + + + + + + + + + `, + ); + } else if (req.url === "/script.js") { + respond(res, "text/javascript", "function f () {}"); + } else if (req.url === "/plain.txt") { + respond(res, "text/plain", "content"); + } else { + throw new Error("unexpected request path"); + } +}); + +server.on("upgrade", (req, socket, head) => { + wss.handleUpgrade(req, socket, head, (ws) => { + ws.on("message", (buffer) => { + ws.send(buffer); + }); + }); +}); + +server.on("connect", (_req, socket, _head) => { + socket.end("HTTP/1.1 200 Connection Established\r\n\r\n"); +}); + +server.listen(0); + +await new Promise((resolve, reject) => { + server.on("listening", resolve); + server.on("error", reject); +}); + +const host = { name: "localhost", port: server.address().port }; + +///////////////// +// Setup Forge // +///////////////// + +const configuration = extendConfiguration( + createConfiguration("protocol://host/home"), + { + packages: [ + { + regexp: "^", + enabled: true, + }, + ], + hooks: { + apply: "__HIDDEN__", + }, + "http-switch": "__SWITCH__", + }, + null, +); + +const backend = createBackend(configuration); + +const forge = createHttpServer(); + +forge.on("request", (inc_req, out_res) => { + interceptRequest(configuration, backend, host, inc_req, out_res); +}); + +forge.on("upgrade", (req, socket, head) => { + interceptUpgrade(configuration, backend, wss, host, req, socket, head); +}); + +forge.on("connect", (req, socket, head) => { + interceptConnect(configuration, backend, host, req, socket, head); +}); + +forge.listen(0); + +await new Promise((resolve, reject) => { + forge.on("listening", resolve); + forge.on("error", reject); +}); + +///////////////////// +// Request >> html // +///////////////////// + +{ + const req = requestHttp({ + host: "localhost", + port: forge.address().port, + method: "GET", + path: "/index.html", + version: "1.1", + }); + req.end(); + const res = await new Promise((resolve, reject) => { + req.on("response", resolve); + req.on("error", reject); + }); + const scripts = [ + ...( + await new Promise((resolve) => { + bufferReadable(res, resolve); + }) + ) + .toString("utf8") + .matchAll(/]*>([\s\S]*?)<\/script>/gu), + ].map((match) => match[1]); + assertEqual(scripts.length, 2); + assertMatch(scripts[0], /__APPMAP_CONFIGURATION__/u); + assertMatch(scripts[1], /__HIDDEN__/u); +} + +/////////////////// +// Request >> js // +/////////////////// + +{ + const req = requestHttp({ + host: "localhost", + port: forge.address().port, + method: "GET", + path: "/script.js", + version: "1.1", + }); + req.end(); + const res = await new Promise((resolve, reject) => { + req.on("response", resolve); + req.on("error", reject); + }); + assertMatch( + ( + await new Promise((resolve) => { + bufferReadable(res, resolve); + }) + ).toString("utf8"), + /^\s*function\s*f\s*\(\s*\)[\s\S]*__HIDDEN__/u, + ); +} + +////////////////////// +// Request >> plain // +////////////////////// + +{ + const req = requestHttp({ + host: "localhost", + port: forge.address().port, + method: "GET", + path: "/plain.txt", + version: "1.1", + }); + req.end(); + const res = await new Promise((resolve, reject) => { + req.on("response", resolve); + req.on("error", reject); + }); + assertEqual( + ( + await new Promise((resolve) => { + bufferReadable(res, resolve); + }) + ).toString("utf8"), + "content", + ); +} + +//////////////////////// +// Upgrade >> forward // +//////////////////////// + +{ + const ws = new WebSocket(`ws://localhost:${String(forge.address().port)}`); + await new Promise((resolve, reject) => { + ws.on("open", resolve); + ws.on("error", reject); + }); + ws.send(toBuffer("message", "utf8")); + assertEqual( + ( + await new Promise((resolve, reject) => { + ws.on("message", resolve); + ws.on("error", reject); + }) + ).toString("utf8"), + "message", + ); + ws.close(); + await new Promise((resolve, reject) => { + ws.on("close", resolve); + ws.on("error", reject); + }); +} + +////////////////////////// +// Upgrade >> Intercept // +////////////////////////// + +{ + const ws = new WebSocket( + `ws://localhost:${String(forge.address().port)}/__SWITCH__`, + ); + await new Promise((resolve, reject) => { + ws.on("open", resolve); + ws.on("error", reject); + }); + ws.send( + toBuffer( + stringifyJSON({ + type: "error", + session: "session", + error: { type: "number", print: "123" }, + }), + ), + ); + ws.close(); + await new Promise((resolve, reject) => { + ws.on("close", resolve); + ws.on("error", reject); + }); +} + +///////////// +// Connect // +///////////// + +{ + const req = requestHttp({ + host: "localhost", + port: forge.address().port, + method: "CONNECT", + path: "/", + version: "1.1", + }); + req.end(); + assertEqual( + ( + await new Promise((resolve, reject) => { + req.on("connect", resolve); + req.on("error", reject); + }) + ).statusCode, + 200, + ); +} + +////////////// +// Teardown // +////////////// + +forge.close(); + +await new Promise((resolve, reject) => { + forge.on("error", reject); + forge.on("close", resolve); +}); + +server.close(); + +await new Promise((resolve, reject) => { + server.on("error", reject); + server.on("close", resolve); +}); diff --git a/components/mitm/default/js.mjs b/components/mitm/default/js.mjs new file mode 100644 index 000000000..4353d33fc --- /dev/null +++ b/components/mitm/default/js.mjs @@ -0,0 +1,15 @@ +import { sendBackend } from "../../backend/index.mjs"; +import { createSource, toSourceMessage } from "../../source/index.mjs"; +import { loadSourceMap, fillSourceMap } from "../../mapping-file/index.mjs"; +import { instrument } from "../../instrumentation/index.mjs"; + +export const instrumentJs = (configuration, backend, file) => { + const source = createSource(file.url, file.content); + const mapping = loadSourceMap(source, null); + fillSourceMap(mapping, configuration); + const { content, sources } = instrument(configuration, source, mapping); + for (const source of sources) { + sendBackend(backend, toSourceMessage(source)); + } + return content; +}; diff --git a/components/mitm/default/js.test.mjs b/components/mitm/default/js.test.mjs new file mode 100644 index 000000000..868e460a4 --- /dev/null +++ b/components/mitm/default/js.test.mjs @@ -0,0 +1,27 @@ +import { assertEqual } from "../../__fixture__.mjs"; +import { + createConfiguration, + extendConfiguration, +} from "../../configuration/index.mjs"; +import { createBackend } from "../../backend/index.mjs"; +import { instrumentJs } from "./js.mjs"; + +{ + const configuration = extendConfiguration( + createConfiguration("protocol://host/home"), + { + packages: { + regexp: "script.js", + enabled: true, + }, + }, + "protocol://host/base", + ); + assertEqual( + instrumentJs(configuration, createBackend(configuration), { + url: "protocol://host/base/script.js", + content: "123;", + }), + "123;\n", + ); +} diff --git a/components/mitm/default/proxy.mjs b/components/mitm/default/proxy.mjs new file mode 100644 index 000000000..2c5697959 --- /dev/null +++ b/components/mitm/default/proxy.mjs @@ -0,0 +1,109 @@ +import { Socket as NetSocket } from "node:net"; +import { platform } from "node:process"; +import { createServer as createHttpServer } from "node:http"; +import { tmpdir } from "node:os"; +import { addPool } from "../../pool/index.mjs"; +import { logDebug, logWarning } from "../../log/index.mjs"; +import { getUuid } from "../../uuid/random/index.mjs"; +import { + partialx_, + partialx__, + partialx___, + toSocketAddress, + parseHost, +} from "./util.mjs"; + +const HEAD = [ + "HTTP/1.1 200 Connection Established", + "Proxy-agent: AppmapProxy", + "", + "", +].join("\r\n"); + +const getFreshPort = () => { + /* c8 ignore start */ + if (platform === "win32") { + return 0; + } else { + return `${tmpdir()}/${getUuid()}}`; + } + /* c8 ignore stop */ +}; + +const forge = (servers, pool, host_string, handlers) => { + if (servers.has(host_string)) { + return servers.get(host_string); + } else { + const host = parseHost(host_string); + const server = createHttpServer(); + server.on("connection", partialx_(addPool, pool)); + server.on("request", partialx__(handlers.request, host)); + server.on("upgrade", partialx___(handlers.upgrade, host)); + server.on("connect", partialx___(handlers.connect, host)); + server.listen(getFreshPort()); + servers.set(host_string, server); + return server; + } +}; + +export const requestProxy = (request, req, res) => { + request(parseHost(req.headers.host), req, res); +}; + +export const upgradeProxy = (upgrade, req, socket, head) => { + upgrade(parseHost(req.headers.host), req, socket, head); +}; + +const connect = (server, req, socket, head) => { + const forward_socket = new NetSocket(); + /* c8 ignore start */ + forward_socket.on("error", () => { + socket.destroy(); + }); + socket.on("error", () => { + forward_socket.destroy(); + }); + forward_socket.on("error", (error) => { + logWarning( + "error on forward proxy socket >> %s %s %j >> %o", + req.method, + req.url, + req.headers, + error, + ); + }); + /* c8 ignore stop */ + forward_socket.connect(toSocketAddress(server.address())); + forward_socket.on("connect", () => { + socket.write(HEAD); + forward_socket.write(head); + socket.pipe(forward_socket); + forward_socket.pipe(socket); + }); +}; + +export const connectProxy = (handlers, servers, pool, req, socket, head) => { + logDebug("proxy CONNECT %s %j", req.url, req.headers); + /* c8 ignore start */ + socket.on("error", (error) => { + logWarning( + "error on proxy socket >> %s %s %j >> %o", + req.method, + req.url, + req.headers, + error, + ); + }); + /* c8 ignore stop */ + const { url: host_string } = req; + const server = forge(servers, pool, host_string, handlers); + /* c8 ignore start */ + if (server.listening) { + connect(server, req, socket, head); + } else { + server.on("listening", () => { + connect(server, req, socket, head); + }); + } + /* c8 ignore stop */ +}; diff --git a/components/mitm/default/proxy.test.mjs b/components/mitm/default/proxy.test.mjs new file mode 100644 index 000000000..ff4202f91 --- /dev/null +++ b/components/mitm/default/proxy.test.mjs @@ -0,0 +1,284 @@ +import { + request as requestHttp, + createServer as createHttpServer, +} from "node:http"; +import { Buffer } from "node:buffer"; +import { assertEqual } from "../../__fixture__.mjs"; +import { createPool } from "../../pool/index.mjs"; +import { requestProxy, upgradeProxy, connectProxy } from "./proxy.mjs"; + +const { Error, Map, Promise, String } = globalThis; + +const { from: toBuffer } = Buffer; + +/////////// +// Setup // +/////////// + +const pool = createPool(); + +const proxy = createHttpServer(); + +const servers = new Map(); + +const handlers = { + request: (host, _req, res) => { + res.writeHead(200, { + "host-name": host.name, + "host-port": String(host.port), + }); + res.end(); + }, + connect: (host, _req, socket, _head) => { + socket.write( + toBuffer( + [ + "HTTP/1.1 200 Connection Established", + `Host-Name: ${host.name}`, + `Host-Port: ${String(host.port)}`, + "", + "", + ].join("\r\n"), + "utf8", + ), + ); + }, + upgrade: (host, _req, socket, _head) => { + socket.end( + toBuffer( + [ + "HTTP/1.1 101 Switching Protocols", + "Connection: Upgrade", + "Upgrade: HTTP/2", + `Host-Name: ${host.name}`, + `Host-Port: ${String(host.port)}`, + "", + "", + ].join("\r\n"), + "utf8", + ), + ); + }, +}; + +proxy.on("request", (req, res) => { + requestProxy(handlers.request, req, res); +}); + +proxy.on("upgrade", (req, socket, head) => { + upgradeProxy(handlers.upgrade, req, socket, head); +}); + +proxy.on("connect", (req, socket, head) => { + connectProxy(handlers, servers, pool, req, socket, head); +}); + +proxy.listen(0); + +await new Promise((resolve, reject) => { + proxy.on("listening", resolve); + proxy.on("error", reject); +}); + +/////////////////////////////// +// Direct >> Regular Request // +/////////////////////////////// + +{ + const req = requestHttp({ + host: "localhost", + port: proxy.address().port, + method: "GET", + path: "/", + version: "1.1", + headers: { + host: "example.org:1234", + }, + }); + req.end(); + const res = await new Promise((resolve, reject) => { + req.on("response", resolve); + req.on("error", reject); + }); + assertEqual(res.statusCode, 200); + assertEqual(res.headers["host-name"], "example.org"); + assertEqual(res.headers["host-port"], "1234"); + await new Promise((resolve, reject) => { + res.on("end", resolve); + res.on("data", () => { + reject(new Error("unexpected data")); + }); + res.on("error", reject); + }); +} + +/////////////////////////////// +// Direct >> Upgrade Request // +/////////////////////////////// + +{ + const req = requestHttp({ + host: "localhost", + port: proxy.address().port, + method: "GET", + path: "/", + version: "1.1", + headers: { + host: "example.org:1234", + connection: "upgrade", + upgrade: "HTTP/2", + }, + }); + req.end(); + const res = await new Promise((resolve, reject) => { + req.on("response", (_res) => { + reject(new Error("regular response to upgrade request")); + }); + req.on("upgrade", resolve); + req.on("error", reject); + }); + assertEqual(res.statusCode, 101); + assertEqual(res.headers["host-name"], "example.org"); + assertEqual(res.headers["host-port"], "1234"); + await new Promise((resolve, reject) => { + res.on("end", resolve); + res.on("data", () => { + reject(new Error("unexpected data")); + }); + res.on("error", reject); + }); +} + +//////////// +// Tunnel // +//////////// + +const connectAsync = async (host1, host2) => { + const req = requestHttp({ + host: host1.name, + port: host1.port, + method: "CONNECT", + path: `${host2.name}:${String(host2.port)}`, + version: "1.1", + }); + req.end(); + const [res, socket, _head] = await new Promise((resolve, reject) => { + req.on("response", (_res) => { + reject(new Error("regular response to connect request")); + }); + req.on("connect", (res, socket, head) => { + resolve([res, socket, head]); + }); + req.on("error", reject); + }); + assertEqual(res.statusCode, 200); + assertEqual(res.statusMessage, "Connection Established"); + await new Promise((resolve, reject) => { + res.on("end", resolve); + res.on("data", () => { + reject(new Error("unexpected data")); + }); + res.on("error", reject); + }); + return socket; +}; + +/////////////////////////////// +// Tunnel >> Regular Request // +/////////////////////////////// + +{ + const socket = await connectAsync( + { name: "localhost", port: proxy.address().port }, + { name: "example.org", port: 1234 }, + ); + const req = requestHttp({ + createConnection: (_options, _callback) => socket, + method: "GET", + path: "/", + version: "1.1", + }); + req.end(); + const res = await new Promise((resolve, reject) => { + req.on("response", resolve); + req.on("error", reject); + }); + assertEqual(res.statusCode, 200); + assertEqual(res.headers["host-name"], "example.org"); + assertEqual(res.headers["host-port"], "1234"); + await new Promise((resolve, reject) => { + res.on("end", resolve); + res.on("data", () => { + reject(new Error("unexpected data")); + }); + res.on("error", reject); + }); + socket.end(); + await new Promise((resolve, reject) => { + socket.on("error", reject); + socket.on("close", resolve); + }); +} + +/////////////////////////////// +// Tunnel >> Upgrade Request // +/////////////////////////////// + +{ + const socket = await connectAsync( + { name: "localhost", port: proxy.address().port }, + { name: "example.org", port: 1234 }, + ); + const req = requestHttp({ + createConnection: (_options, _callback) => socket, + method: "GET", + path: "/", + version: "1.1", + headers: { + connection: "upgrade", + upgrade: "HTTP/2", + }, + }); + req.end(); + const res = await new Promise((resolve, reject) => { + req.on("response", (_res) => { + reject(new Error("regular response to upgrade request")); + }); + req.on("upgrade", resolve); + req.on("error", reject); + }); + assertEqual(res.statusCode, 101); + assertEqual(res.headers["host-name"], "example.org"); + assertEqual(res.headers["host-port"], "1234"); + await new Promise((resolve, reject) => { + res.on("end", resolve); + res.on("data", () => { + reject(new Error("unexpected data")); + }); + res.on("error", reject); + }); + socket.end(); + await new Promise((resolve, reject) => { + socket.on("error", reject); + socket.on("close", resolve); + }); +} + +////////////// +// Teardown // +////////////// + +proxy.close(); + +await new Promise((resolve, reject) => { + proxy.on("error", reject); + proxy.on("close", resolve); +}); + +for (const server of servers.values()) { + server.close(); + await new Promise((resolve, reject) => { + server.on("error", reject); + server.on("close", resolve); + }); +} diff --git a/components/mitm/default/stream.mjs b/components/mitm/default/stream.mjs new file mode 100644 index 000000000..e9ca62c26 --- /dev/null +++ b/components/mitm/default/stream.mjs @@ -0,0 +1,12 @@ +import { Buffer } from "node:buffer"; +const { concat: concatBufferArray } = Buffer; + +export const bufferReadable = (readable, callback) => { + const buffers = []; + readable.on("data", (buffer) => { + buffers.push(buffer); + }); + readable.on("end", () => { + callback(concatBufferArray(buffers)); + }); +}; diff --git a/components/mitm/default/stream.test.mjs b/components/mitm/default/stream.test.mjs new file mode 100644 index 000000000..c2dca70a7 --- /dev/null +++ b/components/mitm/default/stream.test.mjs @@ -0,0 +1,36 @@ +import { Readable } from "node:stream"; +import { Buffer } from "node:buffer"; +import { assertEqual } from "../../__fixture__.mjs"; +import { bufferReadable } from "./stream.mjs"; + +const { Promise, setTimeout } = globalThis; + +const { from: toBuffer } = Buffer; + +let done = false; + +const readable = new Readable({ + read() { + if (!done) { + done = true; + setTimeout(() => { + this.push(toBuffer("foo", "utf8")); + setTimeout(() => { + this.push(toBuffer("bar", "utf8")); + setTimeout(() => { + this.push(null); + }, 0); + }, 0); + }, 0); + } + }, +}); + +assertEqual( + ( + await new Promise((resolve) => { + bufferReadable(readable, resolve); + }) + ).toString("utf8"), + "foobar", +); diff --git a/components/mitm/default/util.mjs b/components/mitm/default/util.mjs new file mode 100644 index 000000000..c19628032 --- /dev/null +++ b/components/mitm/default/util.mjs @@ -0,0 +1,51 @@ +import { URL } from "../../url/index.mjs"; +import { InternalAppmapError } from "../../error/index.mjs"; + +const { String, parseInt } = globalThis; + +export const partialx_ = (f, x1) => (x2) => f(x1, x2); +export const partialx__ = (f, x1) => (x2, x3) => f(x1, x2, x3); +export const partialxx_ = (f, x1, x2) => (x3) => f(x1, x2, x3); +export const partialxx__ = (f, x1, x2) => (x3, x4) => f(x1, x2, x3, x4); +export const partialx___ = (f, x1) => (x2, x3, x4) => f(x1, x2, x3, x4); +export const partialxx___ = (f, x1, x2) => (x3, x4, x5) => + f(x1, x2, x3, x4, x5); +export const partialxxx___ = (f, x1, x2, x3) => (x4, x5, x6) => + f(x1, x2, x3, x4, x5, x6); +export const partialxx____ = (f, x1, x2) => (x3, x4, x5, x6) => + f(x1, x2, x3, x4, x5, x6); +export const partialxxx____ = (f, x1, x2, x3) => (x4, x5, x6, x7) => + f(x1, x2, x3, x4, x5, x6, x7); + +export const resolveHostPath = ({ name, port }, path) => + new URL(path, `http://${name}:${String(port)}`).href; + +export const parseHost = (host_string) => { + const { hostname: name, port: port_string } = new URL( + `http://${host_string}`, + ); + return { + name, + port: port_string === "" ? 80 : parseInt(port_string), + }; +}; + +export const toPort = (address) => { + if (typeof address === "string") { + return address; + } else if (typeof address === "object" && address !== null) { + return address.port; + } else { + throw new InternalAppmapError("invalid server address"); + } +}; + +export const toSocketAddress = (address) => { + if (typeof address === "string") { + return { path: address }; + } else if (typeof address === "object" && address !== null) { + return { host: "localhost", port: address.port }; + } else { + throw new InternalAppmapError("invalid server address"); + } +}; diff --git a/components/mitm/default/util.test.mjs b/components/mitm/default/util.test.mjs new file mode 100644 index 000000000..c70b46a62 --- /dev/null +++ b/components/mitm/default/util.test.mjs @@ -0,0 +1,91 @@ +import { + assertThrow, + assertDeepEqual, + assertEqual, +} from "../../__fixture__.mjs"; +import * as Util from "./util.mjs"; + +const { Error } = globalThis; + +const { resolveHostPath, parseHost, toSocketAddress, toPort } = Util; + +// partial // + +{ + const returnArgumentArray = (...xs) => xs; + const testPartial = (spec) => { + const xs = []; + const ys = []; + const zs = []; + for (let index = 0; index < spec.length; index += 1) { + if (spec[index] === "x") { + xs.push(index); + } else if (spec[index] === "_") { + ys.push(index); + } else { + throw new Error("invalid spec char"); + } + zs.push(index); + } + assertDeepEqual( + Util[`partial${spec}`](returnArgumentArray, ...xs)(...ys), + zs, + ); + }; + [ + "x_", + "x__", + "xx_", + "xx__", + "x___", + "xx___", + "xxx___", + "xx____", + "xxx____", + ].forEach(testPartial); +} + +// resolveHostPath // + +assertEqual( + resolveHostPath({ name: "name", port: 8080 }, "/path"), + "http://name:8080/path", +); + +assertEqual( + resolveHostPath({ name: "name", port: 8080 }, "http://host/path"), + "http://host/path", +); + +// parseHost // + +assertDeepEqual(parseHost("name:8080"), { name: "name", port: 8080 }); + +assertDeepEqual(parseHost("name"), { name: "name", port: 80 }); + +// toPort // + +assertEqual(toPort("/unix-domain-socket"), "/unix-domain-socket"); + +assertEqual(toPort({ port: 8080 }), 8080); + +assertThrow( + () => toPort(123), + /^InternalAppmapError: invalid server address$/u, +); + +// toSocketAddress // + +assertDeepEqual(toSocketAddress("/unix-domain-socket"), { + path: "/unix-domain-socket", +}); + +assertDeepEqual(toSocketAddress({ port: 8080 }), { + host: "localhost", + port: 8080, +}); + +assertThrow( + () => toSocketAddress(123), + /^InternalAppmapError: invalid server address$/u, +); diff --git a/components/server/default/index.mjs b/components/server/default/index.mjs index eae44ab0d..cd240ae10 100644 --- a/components/server/default/index.mjs +++ b/components/server/default/index.mjs @@ -31,6 +31,11 @@ import { isBackendEmpty, } from "../../backend/index.mjs"; import { spawnAsync, killAllAsync } from "../../spawn/index.mjs"; +import { + openMitmAsync, + getMitmPort, + closeMitmAsync, +} from "../../mitm/index.mjs"; const { Promise, @@ -106,6 +111,13 @@ export const mainAsync = async (process, configuration) => { "trace-port": getReceptorTracePort(receptor), "track-port": getReceptorTrackPort(receptor), }; + const maybe_mitm = + configuration["proxy-port"] === null + ? null + : await openMitmAsync(configuration, backend); + if (maybe_mitm !== null) { + logInfo("proxy listening to %j", getMitmPort(maybe_mitm)); + } const flushing = (async () => { while (!done) { await flushBackendAsync(urls, backend, false); @@ -188,6 +200,9 @@ export const mainAsync = async (process, configuration) => { done = true; } await flushing; + if (maybe_mitm !== null) { + await closeMitmAsync(maybe_mitm); + } await closeReceptorAsync(receptor); return 0; }; diff --git a/components/server/default/index.test.mjs b/components/server/default/index.test.mjs index 6fb04ed19..3b3f62bb4 100644 --- a/components/server/default/index.test.mjs +++ b/components/server/default/index.test.mjs @@ -25,11 +25,12 @@ const configuration = extendConfiguration( createConfiguration(cwd_url), { "command-options": { shell: false }, + "proxy-port": null, }, cwd_url, ); -// endless mode // +// endless mode && no mitm // { const emitter = new EventEmitter(); emitter.env = env; @@ -39,7 +40,7 @@ const configuration = extendConfiguration( assertEqual(await mainAsync(emitter, configuration), 0); } -// multiple child >> SIGINT // +// multiple child >> SIGINT && mitm // { const emitter = new EventEmitter(); const base = getTmpUrl(); @@ -69,6 +70,7 @@ const configuration = extendConfiguration( extendConfiguration( configuration, { + "proxy-port": 0, hooks: { cjs: false, esm: false, diff --git a/package-lock.json b/package-lock.json index 4b073debe..d34d48cec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "astring": "^1.8.4", "chalk": "^5.0.1", "glob": "^8.0.3", + "htmlparser2": "^8.0.1", "klaw-sync": "^6.0.0", "minimatch": "^5.1.0", "minimist": "^1.2.7", @@ -48,6 +49,7 @@ "eslint-plugin-local": "^1.0.0", "express": "^4.17.1", "jest": "^29.3.1", + "jsdom": "^21.1.1", "mocha": "^10.0.0", "mysql": "^2.18.1", "pg": "^8.6.0", @@ -2277,6 +2279,12 @@ "integrity": "sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q==", "dev": true }, + "node_modules/abab": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", + "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", + "dev": true + }, "node_modules/abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -2297,9 +2305,9 @@ } }, "node_modules/acorn": { - "version": "8.8.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.0.tgz", - "integrity": "sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w==", + "version": "8.8.2", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz", + "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==", "dev": true, "bin": { "acorn": "bin/acorn" @@ -2308,6 +2316,16 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-globals": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz", + "integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==", + "dev": true, + "dependencies": { + "acorn": "^8.1.0", + "acorn-walk": "^8.0.2" + } + }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -2587,6 +2605,12 @@ "astring": "bin/astring" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true + }, "node_modules/at-least-node": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", @@ -3199,6 +3223,18 @@ "color-support": "bin.js" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/compare-func": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz", @@ -3447,6 +3483,66 @@ "node": ">=8" } }, + "node_modules/cssstyle": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-3.0.0.tgz", + "integrity": "sha512-N4u2ABATi3Qplzf0hWbVCdjenim8F3ojEXpBDF5hBpjzW182MjNGLqfmQ0SkSPeQ+V86ZXgeH8aXj6kayd4jgg==", + "dev": true, + "dependencies": { + "rrweb-cssom": "^0.6.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/data-urls": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-4.0.0.tgz", + "integrity": "sha512-/mMTei/JXPqvFqQtfyTowxmJVwr2PVAeCcDxyFf6LhoOu/09TX2OX3kb2wzi4DMXcfj4OItwDOnhl5oziPnT6g==", + "dev": true, + "dependencies": { + "abab": "^2.0.6", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^12.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-4.1.1.tgz", + "integrity": "sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==", + "dev": true, + "dependencies": { + "punycode": "^2.3.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-12.0.1.tgz", + "integrity": "sha512-Ed/LrqB8EPlGxjS+TrsXcpUond1mhccS3pchLhzSgPCnTimUCKj3IZE75pAs5m6heB2U2TMerKFUXheyHY+VDQ==", + "dev": true, + "dependencies": { + "tr46": "^4.1.1", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/dateformat": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-3.0.3.tgz", @@ -3504,6 +3600,12 @@ "node": ">=0.10.0" } }, + "node_modules/decimal.js": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", + "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==", + "dev": true + }, "node_modules/dedent": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", @@ -3572,6 +3674,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/delegates": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", @@ -3663,6 +3774,78 @@ "node": ">=6.0.0" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ] + }, + "node_modules/domexception": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", + "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", + "dev": true, + "dependencies": { + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/domexception/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.0.1.tgz", + "integrity": "sha512-z08c1l761iKhDFtfXO04C7kTdPBLi41zwOZl00WS8b5eiaebNpY00HKbztwBq+e3vyqWNwWF3mP9YLUeqIrF+Q==", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.1" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dot-prop": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", @@ -3744,6 +3927,17 @@ "node": ">=0.10.0" } }, + "node_modules/entities": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.4.0.tgz", + "integrity": "sha512-oYp7156SP8LkeGD0GF85ad1X9Ai79WtRsZ2gxJqtBuzH+98YUV6jkHEKlZkMbcrjJjIVJNIDP/3WL9wQkoPbWA==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-ci": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/env-ci/-/env-ci-5.5.0.tgz", @@ -3873,6 +4067,98 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/escodegen": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.0.0.tgz", + "integrity": "sha512-mmHKys/C8BFUGI+MAWNcSYoORYLMdPzjrknd2Vc+bUsjN5bXcr8EhrNB+UTqfL1y3I9c4fw2ihgtMPQLBRiQxw==", + "dev": true, + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2", + "optionator": "^0.8.1" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/escodegen/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/escodegen/node_modules/levn": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", + "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==", + "dev": true, + "dependencies": { + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/escodegen/node_modules/optionator": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", + "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", + "dev": true, + "dependencies": { + "deep-is": "~0.1.3", + "fast-levenshtein": "~2.0.6", + "levn": "~0.3.0", + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2", + "word-wrap": "~1.2.3" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/escodegen/node_modules/prelude-ls": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", + "integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/escodegen/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/escodegen/node_modules/type-check": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", + "integrity": "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==", + "dev": true, + "dependencies": { + "prelude-ls": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/eslint": { "version": "8.24.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.24.0.tgz", @@ -4663,6 +4949,20 @@ "node": ">=8.0.0" } }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dev": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/format-util": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/format-util/-/format-util-1.0.5.tgz", @@ -5130,12 +5430,42 @@ "node": ">=10" } }, + "node_modules/html-encoding-sniffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", + "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "dev": true, + "dependencies": { + "whatwg-encoding": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true }, + "node_modules/htmlparser2": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.1.tgz", + "integrity": "sha512-4lVbmc1diZC7GUJQtRQ5yBAeUCL1exyMwmForWkRLnwyzWBFxN633SALPMGYaWZvKe9j1pRZJpauvmxENSp/EA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "entities": "^4.3.0" + } + }, "node_modules/http-cache-semantics": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz", @@ -5571,6 +5901,12 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true + }, "node_modules/is-regex": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", @@ -6765,6 +7101,108 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-21.1.1.tgz", + "integrity": "sha512-Jjgdmw48RKcdAIQyUD1UdBh2ecH7VqwaXPN3ehoZN6MqgVbMn+lRm1aAT1AsdJRAJpwfa4IpwgzySn61h2qu3w==", + "dev": true, + "dependencies": { + "abab": "^2.0.6", + "acorn": "^8.8.2", + "acorn-globals": "^7.0.0", + "cssstyle": "^3.0.0", + "data-urls": "^4.0.0", + "decimal.js": "^10.4.3", + "domexception": "^4.0.0", + "escodegen": "^2.0.0", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.1", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.2", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.6.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.2", + "w3c-xmlserializer": "^4.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^2.0.0", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^12.0.1", + "ws": "^8.13.0", + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "canvas": "^2.5.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "dev": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/jsdom/node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "dev": true, + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-4.1.1.tgz", + "integrity": "sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==", + "dev": true, + "dependencies": { + "punycode": "^2.3.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-12.0.1.tgz", + "integrity": "sha512-Ed/LrqB8EPlGxjS+TrsXcpUond1mhccS3pchLhzSgPCnTimUCKj3IZE75pAs5m6heB2U2TMerKFUXheyHY+VDQ==", + "dev": true, + "dependencies": { + "tr46": "^4.1.1", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/jsesc": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", @@ -10424,6 +10862,12 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/nwsapi": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.2.tgz", + "integrity": "sha512-90yv+6538zuvUMnN+zCr8LuV6bPFdq50304114vJYJ8RDyK8D5O9Phpbd6SZWgI7PwzmmfN1upeOJlvybDSgCw==", + "dev": true + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -10701,6 +11145,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", + "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", + "dev": true, + "dependencies": { + "entities": "^4.4.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -11184,10 +11640,16 @@ "node": ">= 0.10" } }, + "node_modules/psl": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", + "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==", + "dev": true + }, "node_modules/punycode": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", + "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", "engines": { "node": ">=6" } @@ -11217,6 +11679,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -11554,6 +12022,12 @@ "node": ">=0.10.0" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true + }, "node_modules/resolve": { "version": "1.22.1", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", @@ -11702,6 +12176,12 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz", + "integrity": "sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==", + "dev": true + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -11751,6 +12231,18 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "devOptional": true }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/semantic-release": { "version": "19.0.5", "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-19.0.5.tgz", @@ -12482,6 +12974,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true + }, "node_modules/tar": { "version": "6.1.11", "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.11.tgz", @@ -12675,6 +13173,30 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.2.tgz", + "integrity": "sha512-G9fqXWoYFZgTc2z8Q5zaHy/vJMjm+WV0AkAeHxVCQiEB1b+dGvWzFW6QV07cY5jQ5gRkeid2qIkzkxUnmoQZUQ==", + "dev": true, + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie/node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -12962,6 +13484,16 @@ "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", "dev": true }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -13021,6 +13553,18 @@ "resolved": "https://registry.npmjs.org/vlq/-/vlq-2.0.4.tgz", "integrity": "sha512-aodjPa2wPQFkra1G8CzJBTHXhgk3EVSwxSWXNPr1fgdFLUb8kvLV1iEb6rFgasIsjP82HWI6dsb5Io26DDnasA==" }, + "node_modules/w3c-xmlserializer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", + "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", + "dev": true, + "dependencies": { + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -13030,11 +13574,44 @@ "makeerror": "1.0.12" } }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "dev": true + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true + }, + "node_modules/whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "dev": true, + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "engines": { + "node": ">=12" + } }, "node_modules/whatwg-url": { "version": "5.0.0", @@ -13162,6 +13739,21 @@ } } }, + "node_modules/xml-name-validator": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", + "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -15056,6 +15648,12 @@ "integrity": "sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q==", "dev": true }, + "abab": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", + "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", + "dev": true + }, "abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -15073,11 +15671,21 @@ } }, "acorn": { - "version": "8.8.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.0.tgz", - "integrity": "sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w==", + "version": "8.8.2", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz", + "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==", "dev": true }, + "acorn-globals": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz", + "integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==", + "dev": true, + "requires": { + "acorn": "^8.1.0", + "acorn-walk": "^8.0.2" + } + }, "acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -15292,6 +15900,12 @@ "resolved": "https://registry.npmjs.org/astring/-/astring-1.8.4.tgz", "integrity": "sha512-97a+l2LBU3Op3bBQEff79i/E4jMD2ZLFD8rHx9B6mXyB2uQwhJQYfiDqUwtfjF4QA1F2qs//N6Cw8LetMbQjcw==" }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true + }, "at-least-node": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", @@ -15754,6 +16368,15 @@ "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", "devOptional": true }, + "combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "requires": { + "delayed-stream": "~1.0.0" + } + }, "compare-func": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz", @@ -15951,6 +16574,53 @@ "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==", "dev": true }, + "cssstyle": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-3.0.0.tgz", + "integrity": "sha512-N4u2ABATi3Qplzf0hWbVCdjenim8F3ojEXpBDF5hBpjzW182MjNGLqfmQ0SkSPeQ+V86ZXgeH8aXj6kayd4jgg==", + "dev": true, + "requires": { + "rrweb-cssom": "^0.6.0" + } + }, + "data-urls": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-4.0.0.tgz", + "integrity": "sha512-/mMTei/JXPqvFqQtfyTowxmJVwr2PVAeCcDxyFf6LhoOu/09TX2OX3kb2wzi4DMXcfj4OItwDOnhl5oziPnT6g==", + "dev": true, + "requires": { + "abab": "^2.0.6", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^12.0.0" + }, + "dependencies": { + "tr46": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-4.1.1.tgz", + "integrity": "sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==", + "dev": true, + "requires": { + "punycode": "^2.3.0" + } + }, + "webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true + }, + "whatwg-url": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-12.0.1.tgz", + "integrity": "sha512-Ed/LrqB8EPlGxjS+TrsXcpUond1mhccS3pchLhzSgPCnTimUCKj3IZE75pAs5m6heB2U2TMerKFUXheyHY+VDQ==", + "dev": true, + "requires": { + "tr46": "^4.1.1", + "webidl-conversions": "^7.0.0" + } + } + } + }, "dateformat": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-3.0.3.tgz", @@ -15990,6 +16660,12 @@ } } }, + "decimal.js": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", + "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==", + "dev": true + }, "dedent": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", @@ -16040,6 +16716,12 @@ "slash": "^3.0.0" } }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true + }, "delegates": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", @@ -16106,6 +16788,56 @@ "esutils": "^2.0.2" } }, + "dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "requires": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + } + }, + "domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==" + }, + "domexception": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", + "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", + "dev": true, + "requires": { + "webidl-conversions": "^7.0.0" + }, + "dependencies": { + "webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true + } + } + }, + "domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "requires": { + "domelementtype": "^2.3.0" + } + }, + "domutils": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.0.1.tgz", + "integrity": "sha512-z08c1l761iKhDFtfXO04C7kTdPBLi41zwOZl00WS8b5eiaebNpY00HKbztwBq+e3vyqWNwWF3mP9YLUeqIrF+Q==", + "requires": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.1" + } + }, "dot-prop": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", @@ -16174,6 +16906,11 @@ } } }, + "entities": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.4.0.tgz", + "integrity": "sha512-oYp7156SP8LkeGD0GF85ad1X9Ai79WtRsZ2gxJqtBuzH+98YUV6jkHEKlZkMbcrjJjIVJNIDP/3WL9wQkoPbWA==" + }, "env-ci": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/env-ci/-/env-ci-5.5.0.tgz", @@ -16276,6 +17013,73 @@ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true }, + "escodegen": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.0.0.tgz", + "integrity": "sha512-mmHKys/C8BFUGI+MAWNcSYoORYLMdPzjrknd2Vc+bUsjN5bXcr8EhrNB+UTqfL1y3I9c4fw2ihgtMPQLBRiQxw==", + "dev": true, + "requires": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2", + "optionator": "^0.8.1", + "source-map": "~0.6.1" + }, + "dependencies": { + "estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true + }, + "levn": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", + "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==", + "dev": true, + "requires": { + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2" + } + }, + "optionator": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", + "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", + "dev": true, + "requires": { + "deep-is": "~0.1.3", + "fast-levenshtein": "~2.0.6", + "levn": "~0.3.0", + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2", + "word-wrap": "~1.2.3" + } + }, + "prelude-ls": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", + "integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==", + "dev": true + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "optional": true + }, + "type-check": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", + "integrity": "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==", + "dev": true, + "requires": { + "prelude-ls": "~1.1.2" + } + } + } + }, "eslint": { "version": "8.24.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.24.0.tgz", @@ -16899,6 +17703,17 @@ "signal-exit": "^3.0.2" } }, + "form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dev": true, + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + } + }, "format-util": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/format-util/-/format-util-1.0.5.tgz", @@ -17236,12 +18051,32 @@ "lru-cache": "^6.0.0" } }, + "html-encoding-sniffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", + "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "dev": true, + "requires": { + "whatwg-encoding": "^2.0.0" + } + }, "html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "dev": true }, + "htmlparser2": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.1.tgz", + "integrity": "sha512-4lVbmc1diZC7GUJQtRQ5yBAeUCL1exyMwmForWkRLnwyzWBFxN633SALPMGYaWZvKe9j1pRZJpauvmxENSp/EA==", + "requires": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "entities": "^4.3.0" + } + }, "http-cache-semantics": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz", @@ -17551,6 +18386,12 @@ "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", "dev": true }, + "is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true + }, "is-regex": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", @@ -18451,6 +19292,84 @@ "argparse": "^2.0.1" } }, + "jsdom": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-21.1.1.tgz", + "integrity": "sha512-Jjgdmw48RKcdAIQyUD1UdBh2ecH7VqwaXPN3ehoZN6MqgVbMn+lRm1aAT1AsdJRAJpwfa4IpwgzySn61h2qu3w==", + "dev": true, + "requires": { + "abab": "^2.0.6", + "acorn": "^8.8.2", + "acorn-globals": "^7.0.0", + "cssstyle": "^3.0.0", + "data-urls": "^4.0.0", + "decimal.js": "^10.4.3", + "domexception": "^4.0.0", + "escodegen": "^2.0.0", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.1", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.2", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.6.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.2", + "w3c-xmlserializer": "^4.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^2.0.0", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^12.0.1", + "ws": "^8.13.0", + "xml-name-validator": "^4.0.0" + }, + "dependencies": { + "@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "dev": true + }, + "http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "dev": true, + "requires": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + } + }, + "tr46": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-4.1.1.tgz", + "integrity": "sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==", + "dev": true, + "requires": { + "punycode": "^2.3.0" + } + }, + "webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true + }, + "whatwg-url": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-12.0.1.tgz", + "integrity": "sha512-Ed/LrqB8EPlGxjS+TrsXcpUond1mhccS3pchLhzSgPCnTimUCKj3IZE75pAs5m6heB2U2TMerKFUXheyHY+VDQ==", + "dev": true, + "requires": { + "tr46": "^4.1.1", + "webidl-conversions": "^7.0.0" + } + } + } + }, "jsesc": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", @@ -21079,6 +21998,12 @@ "set-blocking": "^2.0.0" } }, + "nwsapi": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.2.tgz", + "integrity": "sha512-90yv+6538zuvUMnN+zCr8LuV6bPFdq50304114vJYJ8RDyK8D5O9Phpbd6SZWgI7PwzmmfN1upeOJlvybDSgCw==", + "dev": true + }, "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -21273,6 +22198,15 @@ "lines-and-columns": "^1.1.6" } }, + "parse5": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", + "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", + "dev": true, + "requires": { + "entities": "^4.4.0" + } + }, "parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -21627,10 +22561,16 @@ "ipaddr.js": "1.9.1" } }, + "psl": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", + "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==", + "dev": true + }, "punycode": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", + "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==" }, "q": { "version": "1.5.1", @@ -21647,6 +22587,12 @@ "side-channel": "^1.0.4" } }, + "querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true + }, "queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -21898,6 +22844,12 @@ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==" }, + "requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true + }, "resolve": { "version": "1.22.1", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", @@ -22003,6 +22955,12 @@ "fsevents": "~2.3.2" } }, + "rrweb-cssom": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz", + "integrity": "sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==", + "dev": true + }, "run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -22035,6 +22993,15 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "devOptional": true }, + "saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "requires": { + "xmlchars": "^2.2.0" + } + }, "semantic-release": { "version": "19.0.5", "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-19.0.5.tgz", @@ -22613,6 +23580,12 @@ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "dev": true }, + "symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true + }, "tar": { "version": "6.1.11", "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.11.tgz", @@ -22767,6 +23740,26 @@ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", "dev": true }, + "tough-cookie": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.2.tgz", + "integrity": "sha512-G9fqXWoYFZgTc2z8Q5zaHy/vJMjm+WV0AkAeHxVCQiEB1b+dGvWzFW6QV07cY5jQ5gRkeid2qIkzkxUnmoQZUQ==", + "dev": true, + "requires": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "dependencies": { + "universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true + } + } + }, "tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -22968,6 +23961,16 @@ "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", "dev": true }, + "url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "requires": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -23018,6 +24021,15 @@ "resolved": "https://registry.npmjs.org/vlq/-/vlq-2.0.4.tgz", "integrity": "sha512-aodjPa2wPQFkra1G8CzJBTHXhgk3EVSwxSWXNPr1fgdFLUb8kvLV1iEb6rFgasIsjP82HWI6dsb5Io26DDnasA==" }, + "w3c-xmlserializer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", + "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", + "dev": true, + "requires": { + "xml-name-validator": "^4.0.0" + } + }, "walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -23033,6 +24045,32 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "dev": true }, + "whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "dev": true, + "requires": { + "iconv-lite": "0.6.3" + }, + "dependencies": { + "iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "requires": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + } + } + } + }, + "whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true + }, "whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -23124,6 +24162,18 @@ "integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==", "requires": {} }, + "xml-name-validator": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", + "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "dev": true + }, + "xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true + }, "xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 013f1e61b..c8f76b2e2 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "astring": "^1.8.4", "chalk": "^5.0.1", "glob": "^8.0.3", + "htmlparser2": "^8.0.1", "klaw-sync": "^6.0.0", "minimatch": "^5.1.0", "minimist": "^1.2.7", @@ -74,6 +75,7 @@ "eslint-plugin-local": "^1.0.0", "express": "^4.17.1", "jest": "^29.3.1", + "jsdom": "^21.1.1", "mocha": "^10.0.0", "mysql": "^2.18.1", "pg": "^8.6.0", diff --git a/schema/definitions/configuration-external.yaml b/schema/definitions/configuration-external.yaml index 31806b3df..ffec426d6 100644 --- a/schema/definitions/configuration-external.yaml +++ b/schema/definitions/configuration-external.yaml @@ -36,6 +36,8 @@ properties: $ref: session sessions: $ref: regexp + proxy-port: + $ref: port-proxy trace-port: $ref: port trace-protocol: diff --git a/schema/definitions/configuration-internal.yaml b/schema/definitions/configuration-internal.yaml index ac11362cf..00ceda188 100644 --- a/schema/definitions/configuration-internal.yaml +++ b/schema/definitions/configuration-internal.yaml @@ -60,6 +60,8 @@ properties: - $ref: session sessions: $ref: regexp + proxy-port: + $ref: port-proxy trace-port: $ref: port-cooked trace-protocol: diff --git a/schema/definitions/port-proxy.yaml b/schema/definitions/port-proxy.yaml new file mode 100644 index 000000000..e49b6f135 --- /dev/null +++ b/schema/definitions/port-proxy.yaml @@ -0,0 +1,4 @@ +anyOf: + - const: null + - const: 0 + - $ref: port-number diff --git a/test/cases/html/appmap.yml b/test/cases/html/appmap.yml new file mode 100644 index 000000000..d64426ab7 --- /dev/null +++ b/test/cases/html/appmap.yml @@ -0,0 +1,20 @@ +recorder: process +packages: + - regexp: "^http://localhost:8080/index.html" + relative: false + enabled: true + - url: "http://localhost:8080/index.js" + enabled: true +proxy-port: 8888 +ordering: chronological +appmap_dir: . +appmap_file: main +hooks: + cjs: false + esm: false + eval: false + apply: true + http: false + mysql: false + pg: false + sqlite3: false diff --git a/test/cases/html/backend.mjs b/test/cases/html/backend.mjs new file mode 100644 index 000000000..03a23e7e1 --- /dev/null +++ b/test/cases/html/backend.mjs @@ -0,0 +1,68 @@ +import { readFile as readFileCallback } from "node:fs"; +import { createServer as createHttpServer } from "node:http"; + +const { URL, String, Promise } = globalThis; + +const toPath = (url) => new URL(url, "http://localhost").pathname; + +const aliases = { + __proto__: null, + "/": "/index.html", +}; + +const resolveAlias = (path) => (path in aliases ? aliases[path] : path); + +const getExtension = (path) => { + const segments = path.split("."); + return `.${segments[segments.length - 1]}`; +}; + +const extensions = { + __proto__: null, + ".js": "text/javascript", + ".html": "text/html", +}; + +const toContentType = (extension) => + extension in extensions ? extensions[extension] : "text/plain"; + +export const openBackendAsync = async (port) => { + const server = createHttpServer(); + const { url: __url } = import.meta; + server.on("request", (req, res) => { + const path = resolveAlias(toPath(req.url)); + if (req.method === "GET") { + readFileCallback(new URL(`public${path}`, __url), (error, body) => { + if (error) { + res.writeHead(404); + res.end(); + } else { + res.writeHead(200, { + "content-type": `${toContentType( + getExtension(path), + )}; charset=utf-8`, + "content-length": String(body.length), + }); + res.end(body); + } + }); + } else { + res.writeHead(400); + res.end(); + } + }); + server.listen(port); + await new Promise((resolve, reject) => { + server.on("error", reject); + server.on("listening", resolve); + }); + return server; +}; + +export const closeBackendAsync = async (server) => { + server.close(); + await new Promise((resolve, reject) => { + server.on("error", reject); + server.on("close", resolve); + }); +}; diff --git a/test/cases/html/base.mjs b/test/cases/html/base.mjs new file mode 100644 index 000000000..1cd32ae3e --- /dev/null +++ b/test/cases/html/base.mjs @@ -0,0 +1,66 @@ +// This file test the frontend + backend without appmap. +// It is only used manually to diagnose issues. + +import { + createServer as createHttpServer, + request as requestHttp, +} from "node:http"; +import { spawn } from "node:child_process"; +import { openBackendAsync, closeBackendAsync } from "./backend.mjs"; + +const { URL, String, parseInt, Promise } = globalThis; + +const { url: __url } = import.meta; + +const backend_port = 8080; +const proxy_port = 8888; + +const backend = await openBackendAsync(backend_port); + +const proxy = createHttpServer(); + +proxy.listen(proxy_port); + +await new Promise((resolve, reject) => { + proxy.on("error", reject); + proxy.on("listening", resolve); +}); + +proxy.on("request", (inc_req, out_res) => { + const out_req = requestHttp({ + host: inc_req.headers.host.split(":")[0], + port: parseInt(inc_req.headers.host.split(":")[1]), + method: inc_req.method, + path: inc_req.url, + headers: inc_req.headers, + }); + inc_req.pipe(out_req); + out_req.on("response", (inc_res) => { + out_res.writeHead( + inc_res.statusCode, + inc_res.statusMessage, + inc_res.headers, + ); + inc_res.pipe(out_res); + }); +}); + +const frontend = spawn( + "node", + ["frontend.mjs", String(backend_port), String(proxy_port)], + { stdio: "inherit", cwd: new URL(".", __url) }, +); + +await new Promise((resolve, reject) => { + frontend.on("error", reject); + frontend.on("close", resolve); +}); + +proxy.close(); + +await new Promise((resolve, reject) => { + proxy.on("error", reject); + proxy.on("close", resolve); +}); + +await closeBackendAsync(backend); diff --git a/test/cases/html/frontend.mjs b/test/cases/html/frontend.mjs new file mode 100644 index 000000000..fe1fbd1cd --- /dev/null +++ b/test/cases/html/frontend.mjs @@ -0,0 +1,46 @@ +import { exit, argv } from "node:process"; +import { URL } from "node:url"; +import { ResourceLoader, JSDOM } from "jsdom"; + +const { + parseInt, + String, + Promise, + setTimeout, + Reflect: { defineProperty }, +} = globalThis; + +const { fromURL: loadDomAsync } = JSDOM; + +const backend_port = parseInt(argv[2]); +const proxy_port = parseInt(argv[3]); + +await loadDomAsync(`http://localhost:${String(backend_port)}/index.html`, { + runScripts: "dangerously", + resources: new ResourceLoader({ + proxy: `http://localhost:${String(proxy_port)}`, + }), + // Unfortunately, in jsdom, a proxy cannot be configured for WebSocket. + beforeParse: (window) => { + const { WebSocket: JsdomWebSocket } = window; + defineProperty(window, "WebSocket", { + __proto__: null, + value: function WebSocket(url) { + const url_obj = new URL(url); + url_obj.port = String(proxy_port); + return new JsdomWebSocket(url_obj.toString()); + }, + writable: true, + enumerable: false, + configurable: true, + }); + }, +}); + +// Wait for the websocket to flush its data +await new Promise((resolve) => { + setTimeout(resolve, 3000); +}); + +// The websocket created in the prelude prevent exit +exit(0); diff --git a/test/cases/html/process/main.subset.yaml b/test/cases/html/process/main.subset.yaml new file mode 100644 index 000000000..bfbbe1dae --- /dev/null +++ b/test/cases/html/process/main.subset.yaml @@ -0,0 +1,13 @@ +events: + - event: call + id: 1 + method_id: internal + - event: return + id: 2 + parent_id: 1 + - event: call + id: 3 + method_id: external + - event: return + id: 4 + parent_id: 3 diff --git a/test/cases/html/public/index.html b/test/cases/html/public/index.html new file mode 100644 index 000000000..652b753e1 --- /dev/null +++ b/test/cases/html/public/index.html @@ -0,0 +1,12 @@ + + + + + + + Hello World + + + diff --git a/test/cases/html/public/index.js b/test/cases/html/public/index.js new file mode 100644 index 000000000..4dae19c8a --- /dev/null +++ b/test/cases/html/public/index.js @@ -0,0 +1 @@ +(function external() {})(); diff --git a/test/cases/html/spec.mjs b/test/cases/html/spec.mjs new file mode 100644 index 000000000..a6cef3d39 --- /dev/null +++ b/test/cases/html/spec.mjs @@ -0,0 +1,42 @@ +import { argv } from "node:process"; +import { spawn } from "node:child_process"; +import { openBackendAsync, closeBackendAsync } from "./backend.mjs"; + +const { URL, setTimeout, Promise, String } = globalThis; + +const { url: __url } = import.meta; + +const bin_path = argv[2]; +const backend_port = 8080; +const proxy_port = 8888; + +const backend = await openBackendAsync(backend_port); + +const appmap = spawn("node", [bin_path], { + stdio: "inherit", + cwd: new URL(".", __url), +}); + +await new Promise((resolve) => { + setTimeout(resolve, 3000); +}); + +const frontend = spawn( + "node", + ["frontend.mjs", String(backend_port), String(proxy_port)], + { stdio: "inherit", cwd: new URL(".", __url) }, +); + +await new Promise((resolve, reject) => { + frontend.on("error", reject); + frontend.on("close", resolve); +}); + +appmap.kill("SIGINT"); + +await new Promise((resolve, reject) => { + appmap.on("error", reject); + appmap.on("close", resolve); +}); + +await closeBackendAsync(backend); diff --git a/test/cases/html/spec.yaml b/test/cases/html/spec.yaml new file mode 100644 index 000000000..3a2ad32f2 --- /dev/null +++ b/test/cases/html/spec.yaml @@ -0,0 +1,8 @@ +# not sure what is happening but jsdom +# does not seems to interact weel with +# windows. +# Even `node test/cases/html/base.mjs` +# does not work... +os: ^(?!win32$) +commands: + - node spec.mjs $1