hoversource 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/proxy.js ADDED
@@ -0,0 +1,220 @@
1
+ import http from "node:http";
2
+ import https from "node:https";
3
+ import { URL } from "node:url";
4
+ import { ProxyResponsePipeline } from "./pipeline.js";
5
+ import { getOrCreateLocalSslCert } from "./cert.js";
6
+ export function rewriteCookieHeaders(headers, useHttps) {
7
+ const setCookie = headers["set-cookie"];
8
+ if (setCookie) {
9
+ headers["set-cookie"] = setCookie.map((cookie) => {
10
+ let rewritten = cookie.replace(/;\s*domain\s*=\s*[^;]+/gi, "");
11
+ if (!useHttps) {
12
+ rewritten = rewritten.replace(/;\s*secure\b/gi, "");
13
+ }
14
+ return rewritten;
15
+ });
16
+ }
17
+ }
18
+ /**
19
+ * Starts a reverse HTTP/HTTPS proxy that forwards requests to `targetUrl` and
20
+ * injects `<script src="${overlayScriptUrl}"></script>` into HTML responses.
21
+ */
22
+ export function startProxy(options) {
23
+ const { targetUrl, proxyPort, companionPort, overlayScriptUrl, useHttps, sslKeyPath, sslCertPath } = options;
24
+ const target = new URL(targetUrl);
25
+ const pipeline = new ProxyResponsePipeline();
26
+ const requestHandler = (req, res) => {
27
+ // ─── PART A: Proxy requests under /hoversource/ to Companion Server ──────
28
+ if (req.url?.startsWith("/hoversource/")) {
29
+ const rewrittenPath = req.url.replace(/^\/hoversource/, "") || "/";
30
+ const isOverlayScript = rewrittenPath === "/hoversource-overlay.js";
31
+ const companionReqOpts = {
32
+ hostname: "127.0.0.1",
33
+ port: companionPort,
34
+ path: rewrittenPath,
35
+ method: req.method,
36
+ headers: {
37
+ ...req.headers,
38
+ host: `127.0.0.1:${companionPort}`,
39
+ },
40
+ };
41
+ const companionReq = http.request(companionReqOpts, (companionRes) => {
42
+ const responseHeaders = { ...companionRes.headers };
43
+ // Ensure CSP is stripped from companion server responses just in case
44
+ delete responseHeaders["content-security-policy"];
45
+ delete responseHeaders["content-security-policy-report-only"];
46
+ if (isOverlayScript) {
47
+ const chunks = [];
48
+ companionRes.on("data", (chunk) => chunks.push(chunk));
49
+ companionRes.on("end", () => {
50
+ let body = Buffer.concat(chunks).toString("utf-8");
51
+ body = `globalThis.__HOVERSOURCE_PROXY__ = true;\n` + body;
52
+ const bodyBuf = Buffer.from(body, "utf-8");
53
+ delete responseHeaders["content-length"];
54
+ res.writeHead(companionRes.statusCode || 200, responseHeaders);
55
+ res.end(bodyBuf);
56
+ });
57
+ }
58
+ else {
59
+ res.writeHead(companionRes.statusCode || 200, responseHeaders);
60
+ companionRes.pipe(res, { end: true });
61
+ }
62
+ });
63
+ companionReq.on("error", (err) => {
64
+ console.error(`[HoverSource Proxy] Failed to proxy request to companion server:`, err.message);
65
+ if (!res.headersSent) {
66
+ res.writeHead(502, { "Content-Type": "text/plain" });
67
+ res.end(`HoverSource proxy: companion server upstream error.`);
68
+ }
69
+ });
70
+ req.pipe(companionReq, { end: true });
71
+ return;
72
+ }
73
+ // ─── PART B: Proxy target application requests ─────────────────────────
74
+ const headers = { ...req.headers };
75
+ // Filter accept-encoding to only allow compression formats that our pipeline/zlib can handle
76
+ if (headers["accept-encoding"]) {
77
+ const accepted = String(headers["accept-encoding"]).toLowerCase();
78
+ const supported = [];
79
+ if (accepted.includes("gzip"))
80
+ supported.push("gzip");
81
+ if (accepted.includes("deflate"))
82
+ supported.push("deflate");
83
+ if (accepted.includes("br"))
84
+ supported.push("br");
85
+ if (supported.length > 0) {
86
+ headers["accept-encoding"] = supported.join(", ");
87
+ }
88
+ else {
89
+ delete headers["accept-encoding"];
90
+ }
91
+ }
92
+ // Map upstream agent request based on target protocol
93
+ const isTargetHttps = target.protocol === "https:";
94
+ const agent = isTargetHttps ? https : http;
95
+ const requestOptions = {
96
+ hostname: target.hostname,
97
+ port: target.port || (isTargetHttps ? 443 : 80),
98
+ path: req.url,
99
+ method: req.method,
100
+ headers: {
101
+ ...headers,
102
+ host: target.host,
103
+ },
104
+ };
105
+ const proxyReq = agent.request(requestOptions, (proxyRes) => {
106
+ const contentType = proxyRes.headers["content-type"] || "";
107
+ // Performance Optimization: Stream non-HTML responses directly
108
+ if (!pipeline.shouldTransform(contentType)) {
109
+ const responseHeaders = { ...proxyRes.headers };
110
+ // Strip CSP headers to avoid iframe and asset restrictions
111
+ delete responseHeaders["content-security-policy"];
112
+ delete responseHeaders["content-security-policy-report-only"];
113
+ // Rewrite cookie domains and secure flag
114
+ rewriteCookieHeaders(responseHeaders, !!useHttps);
115
+ res.writeHead(proxyRes.statusCode || 200, responseHeaders);
116
+ proxyRes.pipe(res, { end: true });
117
+ return;
118
+ }
119
+ // HTML Content: Buffer and transform
120
+ const chunks = [];
121
+ proxyRes.on("data", (chunk) => chunks.push(chunk));
122
+ proxyRes.on("end", () => {
123
+ const rawBody = Buffer.concat(chunks);
124
+ const contentEncoding = (proxyRes.headers["content-encoding"] || "").toString();
125
+ const transformedBody = pipeline.transform(rawBody, contentEncoding, {
126
+ overlayScriptUrl,
127
+ });
128
+ const responseHeaders = { ...proxyRes.headers };
129
+ // Recalculate content headers after modification
130
+ delete responseHeaders["content-length"];
131
+ delete responseHeaders["content-encoding"];
132
+ // Strip CSP headers to ensure the browser loads the overlay script
133
+ delete responseHeaders["content-security-policy"];
134
+ delete responseHeaders["content-security-policy-report-only"];
135
+ responseHeaders["content-type"] = "text/html; charset=utf-8";
136
+ // Rewrite cookie domains and secure flag
137
+ rewriteCookieHeaders(responseHeaders, !!useHttps);
138
+ res.writeHead(proxyRes.statusCode || 200, responseHeaders);
139
+ res.end(transformedBody);
140
+ });
141
+ });
142
+ proxyReq.on("error", (err) => {
143
+ console.error(`[HoverSource Proxy] Error forwarding request to ${targetUrl}:`, err.message);
144
+ if (!res.headersSent) {
145
+ res.writeHead(502, { "Content-Type": "text/plain" });
146
+ res.end(`HoverSource proxy: upstream error -- is your dev server running at ${targetUrl}?`);
147
+ }
148
+ });
149
+ req.pipe(proxyReq, { end: true });
150
+ };
151
+ let server;
152
+ if (useHttps) {
153
+ const credentials = getOrCreateLocalSslCert({
154
+ keyPath: sslKeyPath,
155
+ certPath: sslCertPath,
156
+ targetHost: target.hostname,
157
+ });
158
+ server = https.createServer(credentials, requestHandler);
159
+ }
160
+ else {
161
+ server = http.createServer(requestHandler);
162
+ }
163
+ const upgradeHandler = (req, socket, head) => {
164
+ const isTargetHttps = target.protocol === "https:";
165
+ const agent = isTargetHttps ? https : http;
166
+ const requestOptions = {
167
+ hostname: target.hostname,
168
+ port: target.port || (isTargetHttps ? 443 : 80),
169
+ path: req.url,
170
+ method: req.method,
171
+ headers: {
172
+ ...req.headers,
173
+ host: target.host,
174
+ },
175
+ };
176
+ const proxyReq = agent.request(requestOptions);
177
+ proxyReq.on("upgrade", (proxyRes, proxySocket, proxyHead) => {
178
+ socket.on("error", (err) => {
179
+ if (process.env.DEBUG) {
180
+ console.debug("[HoverSource Proxy] Client socket error:", err.message);
181
+ }
182
+ proxySocket.destroy();
183
+ });
184
+ proxySocket.on("error", (err) => {
185
+ if (process.env.DEBUG) {
186
+ console.debug("[HoverSource Proxy] Target socket error:", err.message);
187
+ }
188
+ socket.destroy();
189
+ });
190
+ let responseHeader = `HTTP/${req.httpVersion} 101 Switching Protocols\r\n`;
191
+ for (const [key, value] of Object.entries(proxyRes.headers)) {
192
+ if (Array.isArray(value)) {
193
+ for (const val of value) {
194
+ responseHeader += `${key}: ${val}\r\n`;
195
+ }
196
+ }
197
+ else if (value !== undefined) {
198
+ responseHeader += `${key}: ${value}\r\n`;
199
+ }
200
+ }
201
+ responseHeader += "\r\n";
202
+ socket.write(responseHeader);
203
+ proxySocket.write(proxyHead);
204
+ socket.pipe(proxySocket);
205
+ proxySocket.pipe(socket);
206
+ });
207
+ proxyReq.on("error", (err) => {
208
+ console.error(`[HoverSource Proxy] WebSocket proxy error forwarding upgrade to ${targetUrl}:`, err.message);
209
+ socket.end();
210
+ });
211
+ proxyReq.end();
212
+ };
213
+ server.on("upgrade", upgradeHandler);
214
+ return new Promise((resolve, reject) => {
215
+ server.listen(proxyPort, () => {
216
+ resolve();
217
+ });
218
+ server.on("error", reject);
219
+ });
220
+ }
@@ -0,0 +1,3 @@
1
+ export declare function recordPatchState(filePath: string, originalContent: string): void;
2
+ export declare function removePatchState(filePath: string): void;
3
+ export declare function restoreLeftoverPatches(): void;
@@ -0,0 +1,59 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ const PATCH_STATE_FILE = path.join(os.tmpdir(), "hoversource_patches.json");
5
+ export function recordPatchState(filePath, originalContent) {
6
+ let state = {};
7
+ if (fs.existsSync(PATCH_STATE_FILE)) {
8
+ try {
9
+ state = JSON.parse(fs.readFileSync(PATCH_STATE_FILE, "utf-8"));
10
+ }
11
+ catch (err) {
12
+ console.debug("[HoverSource] Failed to parse patch state:", err);
13
+ }
14
+ }
15
+ state[filePath] = originalContent;
16
+ try {
17
+ fs.writeFileSync(PATCH_STATE_FILE, JSON.stringify(state, null, 2), "utf-8");
18
+ }
19
+ catch (err) {
20
+ console.debug("[HoverSource] Failed to write patch state:", err);
21
+ }
22
+ }
23
+ export function removePatchState(filePath) {
24
+ if (!fs.existsSync(PATCH_STATE_FILE))
25
+ return;
26
+ try {
27
+ const state = JSON.parse(fs.readFileSync(PATCH_STATE_FILE, "utf-8"));
28
+ delete state[filePath];
29
+ if (Object.keys(state).length === 0) {
30
+ fs.unlinkSync(PATCH_STATE_FILE);
31
+ }
32
+ else {
33
+ fs.writeFileSync(PATCH_STATE_FILE, JSON.stringify(state, null, 2), "utf-8");
34
+ }
35
+ }
36
+ catch (err) {
37
+ console.debug("[HoverSource] Failed to remove patch state:", err);
38
+ }
39
+ }
40
+ export function restoreLeftoverPatches() {
41
+ if (!fs.existsSync(PATCH_STATE_FILE))
42
+ return;
43
+ try {
44
+ const state = JSON.parse(fs.readFileSync(PATCH_STATE_FILE, "utf-8"));
45
+ for (const [filePath, originalContent] of Object.entries(state)) {
46
+ try {
47
+ fs.writeFileSync(filePath, originalContent, "utf-8");
48
+ console.log(`[HoverSource] [Self-Healing] Restored leftover patch in ${filePath}`);
49
+ }
50
+ catch (err) {
51
+ console.warn(`[HoverSource] [Self-Healing] Failed to restore leftover patch in ${filePath}:`, err.message);
52
+ }
53
+ }
54
+ fs.unlinkSync(PATCH_STATE_FILE);
55
+ }
56
+ catch (err) {
57
+ console.debug("[HoverSource] Leftover patch restore failed:", err);
58
+ }
59
+ }
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "hoversource",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "main": "./dist/cli.js",
6
+ "bin": {
7
+ "hs": "./dist/cli.js",
8
+ "hoversource": "./dist/cli.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "!dist/**/__tests__",
13
+ "!dist/**/*.test.*"
14
+ ],
15
+ "scripts": {
16
+ "build": "tsc -b",
17
+ "clean": "tsc -b --clean",
18
+ "test": "vitest run src"
19
+ },
20
+ "dependencies": {
21
+ "@hoversource/companion-server": "*",
22
+ "@hoversource/client-injector": "*"
23
+ },
24
+ "devDependencies": {
25
+ "vitest": "^4.1.8"
26
+ }
27
+ }