File size: 6,519 Bytes
6111b2b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 | /**
* HTML rewriter for the embedded-service reverse proxy.
*
* Rewrites an HTML document so that absolute-path URLs point through the
* OmniRoute proxy prefix instead of directly to the embedded service's port.
*
* What it does:
* - Inserts `<base href="${publicPrefix}/">` as the first child of `<head>`.
* - Rewrites path-absolute URLs (starting with `/` but NOT `//`) in selected
* attributes: <a href>, <link href>, <script src>, <img src/srcset>,
* <form action>, <iframe src>, <source src/srcset>.
*
* What it does NOT do (known v1 limitations):
* - CSS `url(...)` rewriting (too complex, CSS parser not included).
* - JS `window.location` rewriting (client-side navigation may break).
* - Multi-URL `srcset` values (comma-separated) β skipped when a comma
* is detected.
*/
import { parse, serialize } from "parse5";
import type { DefaultTreeAdapterMap } from "parse5";
type Document = DefaultTreeAdapterMap["document"];
type Element = DefaultTreeAdapterMap["element"];
type Node = DefaultTreeAdapterMap["node"];
type Attr = { name: string; value: string; namespace?: string; prefix?: string };
// Matches /foo but not //foo, http://, https://, mailto:, javascript:, #, etc.
const ABS_PATH_RE = /^\/(?!\/)/;
// Schemes that should NOT be rewritten β leave them as-is.
const SKIP_SCHEMES = ["http:", "https:", "mailto:", "javascript:", "data:", "blob:", "ftp:"];
const REWRITABLE_ATTRS: Record<string, string[]> = {
a: ["href"],
link: ["href"],
script: ["src"],
img: ["src", "srcset"],
form: ["action"],
iframe: ["src"],
source: ["src", "srcset"],
};
/**
* Rewrite an HTML string so all path-absolute URLs are prefixed with
* `publicPrefix`, and a `<base href>` is injected into `<head>`.
*
* @param html Raw HTML from the upstream service.
* @param publicPrefix The proxy prefix path (e.g. "/dashboard/providers/services/9router/embed").
* Trailing slash is stripped internally.
*/
export function rewriteHtml(html: string, publicPrefix: string): string {
const prefix = publicPrefix.endsWith("/") ? publicPrefix.slice(0, -1) : publicPrefix;
const doc = parse(html) as Document;
const headEl = findOrCreateHead(doc);
injectBase(headEl, `${prefix}/`);
visitNode(doc, prefix);
return serialize(doc);
}
// βββ private helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
/**
* Find the <head> element in the document. parse5 always produces a full tree
* (html β head + body) even for partial HTML, so this should always succeed.
* Falls back to creating a minimal <head> if somehow absent.
*/
function findOrCreateHead(doc: Document): Element {
for (const child of doc.childNodes) {
if (isElement(child) && child.tagName === "html") {
for (const htmlChild of child.childNodes) {
if (isElement(htmlChild) && htmlChild.tagName === "head") {
return htmlChild;
}
}
// <head> missing inside <html> β create one and prepend
const head = makeElement("head");
child.childNodes.unshift(head);
(head as Element & { parentNode: Node }).parentNode = child;
return head;
}
}
// Completely bare fragment β unlikely from parse5, but be safe.
// Find/create <html> too.
const htmlEl = makeElement("html");
const head = makeElement("head");
head.childNodes = [];
(head as Element & { parentNode: Node }).parentNode = htmlEl;
htmlEl.childNodes = [head as Node];
(htmlEl as Element & { parentNode: Node }).parentNode = doc as unknown as Node;
doc.childNodes.push(htmlEl as unknown as Node);
return head;
}
/**
* Prepend `<base href="...">` to `<head>`, but only if one doesn't already
* exist (avoid double-inject on re-proxied pages).
*/
function injectBase(head: Element, baseHref: string): void {
// If a <base> already exists, update its href and exit.
for (const child of head.childNodes) {
if (isElement(child) && child.tagName === "base") {
const hrefAttr = child.attrs.find((a) => a.name === "href");
if (hrefAttr) {
hrefAttr.value = baseHref;
} else {
child.attrs.push({ name: "href", value: baseHref });
}
return;
}
}
// No <base> found β create one and prepend.
const baseEl = makeElement("base");
baseEl.attrs = [{ name: "href", value: baseHref }];
baseEl.childNodes = [];
(baseEl as Element & { parentNode: Node }).parentNode = head;
head.childNodes.unshift(baseEl as unknown as Node);
}
/** Recursively walk the parse5 tree and rewrite matching attrs. */
function visitNode(node: Node, prefix: string): void {
if (isElement(node)) {
const tag = node.tagName.toLowerCase();
const rewritable = REWRITABLE_ATTRS[tag];
if (rewritable) {
for (const attr of node.attrs as Attr[]) {
if (rewritable.includes(attr.name)) {
attr.value = rewriteUrl(attr.value, prefix);
}
}
}
}
const children = (node as { childNodes?: Node[] }).childNodes;
if (children) {
for (const child of children) {
visitNode(child, prefix);
}
}
}
/**
* Rewrite a single URL value:
* - Path-absolute URLs starting with `/` (but not `//`) β prefix + url
* - All other values (relative, external, mailto:, srcset with commas) β unchanged
*/
function rewriteUrl(value: string, prefix: string): string {
const trimmed = value.trim();
if (!trimmed) return value;
// Skip multi-URL srcset (contains comma + space pattern) β too complex for v1
if (trimmed.includes(",")) return value;
// Skip known schemes
for (const scheme of SKIP_SCHEMES) {
if (trimmed.toLowerCase().startsWith(scheme)) return value;
}
// Rewrite path-absolute URLs only
if (ABS_PATH_RE.test(trimmed)) {
return `${prefix}${trimmed}`;
}
return value;
}
function isElement(node: Node): node is Element {
return (node as Element).attrs !== undefined && (node as Element).tagName !== undefined;
}
function makeElement(tag: string): Element {
return {
nodeName: tag,
tagName: tag,
attrs: [],
namespaceURI: "http://www.w3.org/1999/xhtml",
childNodes: [],
parentNode: null as unknown as Node,
} as unknown as Element;
}
|