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;
}