168 lines
6.4 KiB
TypeScript
168 lines
6.4 KiB
TypeScript
import { defineConfig, type Connect } from "vite";
|
|
import react from "@vitejs/plugin-react";
|
|
|
|
// Dev-only middleware that lets the in-OS browser embed external pages.
|
|
// X-Frame-Options / CSP frame-ancestors are enforced by the BROWSER based on the
|
|
// target's response headers and cannot be bypassed client-side. We fetch the page
|
|
// server-side (where those headers are inert), strip the framing headers, inject a
|
|
// <base> tag so relative assets resolve, and keep link navigation inside the proxy.
|
|
function browserProxyPlugin() {
|
|
const handler: Connect.NextHandleFunction = async (req, res, next) => {
|
|
if (!req.url || !req.url.startsWith("/__proxy")) return next();
|
|
const target = new URL(req.url, "http://localhost").searchParams.get("url");
|
|
if (!target || !/^https?:\/\//i.test(target)) {
|
|
res.statusCode = 400;
|
|
res.end("Missing or invalid ?url=");
|
|
return;
|
|
}
|
|
try {
|
|
const upstream = await fetch(target, {
|
|
redirect: "follow",
|
|
headers: {
|
|
"user-agent":
|
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36",
|
|
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
|
"accept-language": "en,de;q=0.8",
|
|
},
|
|
});
|
|
const ctype = upstream.headers.get("content-type") || "application/octet-stream";
|
|
// pass through most headers, but drop framing/security/encoding ones
|
|
const DROP = new Set([
|
|
"x-frame-options",
|
|
"content-security-policy",
|
|
"content-security-policy-report-only",
|
|
"content-encoding",
|
|
"content-length",
|
|
"transfer-encoding",
|
|
"strict-transport-security",
|
|
"set-cookie",
|
|
]);
|
|
upstream.headers.forEach((v, k) => {
|
|
if (!DROP.has(k.toLowerCase())) res.setHeader(k, v);
|
|
});
|
|
res.statusCode = upstream.status;
|
|
|
|
if (ctype.includes("text/html")) {
|
|
let html = await upstream.text();
|
|
const baseHref = upstream.url || target;
|
|
// remove any CSP set via <meta>
|
|
html = html.replace(/<meta[^>]+http-equiv=["']?content-security-policy["']?[^>]*>/gi, "");
|
|
const inject =
|
|
`<base href="${baseHref}">` +
|
|
`<script>(function(){var B=${JSON.stringify(baseHref)};` +
|
|
`function px(u){try{return "/__proxy?url="+encodeURIComponent(new URL(u,B).href);}catch(e){return u;}}` +
|
|
`document.addEventListener("click",function(e){var a=e.target.closest&&e.target.closest("a[href]");` +
|
|
`if(!a)return;var h=a.getAttribute("href");if(!h||h[0]==="#"||/^(javascript|mailto|tel):/i.test(h))return;` +
|
|
`var abs;try{abs=new URL(h,B).href;}catch(_){return;}if(!/^https?:/i.test(abs))return;` +
|
|
`e.preventDefault();window.top.location.href=px(abs);},true);})();</script>`;
|
|
html = /<head[^>]*>/i.test(html)
|
|
? html.replace(/<head[^>]*>/i, (m) => m + inject)
|
|
: inject + html;
|
|
res.setHeader("content-type", "text/html; charset=utf-8");
|
|
res.end(html);
|
|
} else {
|
|
const buf = new Uint8Array(await upstream.arrayBuffer());
|
|
res.setHeader("content-type", ctype);
|
|
res.end(buf);
|
|
}
|
|
} catch (e) {
|
|
res.statusCode = 502;
|
|
res.setHeader("content-type", "text/html; charset=utf-8");
|
|
res.end(
|
|
`<body style="font-family:sans-serif;padding:24px;color:#333">` +
|
|
`<h3>⚠️ Proxy could not load this page</h3>` +
|
|
`<p>${(e as Error).message}</p><p style="color:#888">${target}</p></body>`
|
|
);
|
|
}
|
|
};
|
|
return {
|
|
name: "in-os-browser-proxy",
|
|
configureServer(server: { middlewares: Connect.Server }) {
|
|
server.middlewares.use(handler);
|
|
},
|
|
configurePreviewServer(server: { middlewares: Connect.Server }) {
|
|
server.middlewares.use(handler);
|
|
},
|
|
};
|
|
}
|
|
|
|
// Dynamic AI proxy: the browser POSTs to "/__ai/proxy" with the full upstream
|
|
// endpoint in the "x-llm-url" header. The dev server forwards the request
|
|
// server-side and streams the response back same-origin. This sidesteps CORS
|
|
// for every provider (Anthropic, OpenAI, OpenRouter, Grok, Cerebras) and lets
|
|
// the active provider be switched at runtime without restarting Vite.
|
|
function aiProxyPlugin() {
|
|
// Only these headers are forwarded upstream — auth + protocol, nothing else.
|
|
const FORWARD = new Set([
|
|
"content-type",
|
|
"accept",
|
|
"authorization",
|
|
"x-api-key",
|
|
"anthropic-version",
|
|
"anthropic-dangerous-direct-browser-access",
|
|
"http-referer",
|
|
"x-title",
|
|
]);
|
|
const handler: Connect.NextHandleFunction = async (req, res, next) => {
|
|
if (!req.url || !req.url.startsWith("/__ai/proxy")) return next();
|
|
const target = (req.headers["x-llm-url"] as string) || "";
|
|
if (!/^https?:\/\//i.test(target)) {
|
|
res.statusCode = 400;
|
|
res.end("Missing or invalid x-llm-url header");
|
|
return;
|
|
}
|
|
try {
|
|
const chunks: Buffer[] = [];
|
|
for await (const c of req) chunks.push(c as Buffer);
|
|
const body = Buffer.concat(chunks);
|
|
|
|
const headers: Record<string, string> = {};
|
|
for (const [k, v] of Object.entries(req.headers)) {
|
|
if (FORWARD.has(k.toLowerCase()) && typeof v === "string") headers[k] = v;
|
|
}
|
|
|
|
const upstream = await fetch(target, {
|
|
method: req.method || "POST",
|
|
headers,
|
|
body: req.method === "GET" || req.method === "HEAD" ? undefined : body,
|
|
});
|
|
|
|
res.statusCode = upstream.status;
|
|
const ctype = upstream.headers.get("content-type");
|
|
if (ctype) res.setHeader("content-type", ctype);
|
|
|
|
if (upstream.body) {
|
|
const reader = upstream.body.getReader();
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
if (value) res.write(Buffer.from(value));
|
|
}
|
|
}
|
|
res.end();
|
|
} catch (e) {
|
|
res.statusCode = 502;
|
|
res.setHeader("content-type", "application/json");
|
|
res.end(JSON.stringify({ error: { message: (e as Error).message } }));
|
|
}
|
|
};
|
|
return {
|
|
name: "fluid-os-ai-proxy",
|
|
configureServer(server: { middlewares: Connect.Server }) {
|
|
server.middlewares.use(handler);
|
|
},
|
|
configurePreviewServer(server: { middlewares: Connect.Server }) {
|
|
server.middlewares.use(handler);
|
|
},
|
|
};
|
|
}
|
|
|
|
export default defineConfig(() => {
|
|
return {
|
|
plugins: [react(), browserProxyPlugin(), aiProxyPlugin()],
|
|
server: {
|
|
port: 5173,
|
|
},
|
|
};
|
|
});
|