mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
# Description of Changes Refactor frontend auth to the shared folder and hook it up to both the portal and editor so they share the same system. Also adds various tasks to help run the portal, including `task dev:portal` to spawn the portal with the backend, and `task dev:portal:proxy` to spawn the editor, portal and backend, and a reverse proxy (at localhost:3000) to allow you to use both at once to simulate how this will actually be deployed, allowing you to check whether the seamless transition between the two actually works.
247 lines
8.1 KiB
TypeScript
247 lines
8.1 KiB
TypeScript
/**
|
|
* Single-origin dev server for testing unified auth locally.
|
|
*
|
|
* The editor and portal store their session as a `stirling_jwt` token in
|
|
* localStorage, which the browser scopes per origin. On separate dev ports they
|
|
* can't share it; this server fronts both apps plus the backend on ONE origin
|
|
* so a token from one app is automatically seen by the other - mirroring the
|
|
* real same-origin production topology.
|
|
*
|
|
* Routing (single port):
|
|
* /api, /oauth2, /saml2, /v1/api-docs -> reverse-proxy to the backend
|
|
* /portal, /portal/* -> the portal app
|
|
* everything else -> the editor app (SPA)
|
|
*
|
|
* Each app is served either from a production build (static files) or from a
|
|
* running Vite dev server, chosen per app:
|
|
* - EDITOR_DEV_URL / PORTAL_DEV_URL set -> reverse-proxy to that dev server.
|
|
* - otherwise serve EDITOR_DIST / PORTAL_DIST as static files (SPA fallback).
|
|
*
|
|
* Vite HMR websockets connect straight to each dev server's own port, so live
|
|
* mode keeps hot reload without this proxy needing to multiplex HMR sockets.
|
|
*
|
|
* Config via env: PORT, BACKEND_URL, EDITOR_DEV_URL, PORTAL_DEV_URL,
|
|
* EDITOR_DIST, PORTAL_DIST.
|
|
*/
|
|
import http from "node:http";
|
|
import https from "node:https";
|
|
import { createReadStream } from "node:fs";
|
|
import { stat } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import type { Duplex } from "node:stream";
|
|
|
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
const FRONTEND = path.resolve(here, "..");
|
|
|
|
const PORT = Number(process.env.PORT || 3000);
|
|
const BACKEND_URL = process.env.BACKEND_URL || "http://localhost:8080";
|
|
const EDITOR_DEV_URL = process.env.EDITOR_DEV_URL || "";
|
|
const PORTAL_DEV_URL = process.env.PORTAL_DEV_URL || "";
|
|
const EDITOR_DIST = path.resolve(
|
|
process.env.EDITOR_DIST || path.join(FRONTEND, "editor", "dist"),
|
|
);
|
|
const PORTAL_DIST = path.resolve(
|
|
process.env.PORTAL_DIST || path.join(FRONTEND, "dist-portal"),
|
|
);
|
|
|
|
const backend = new URL(BACKEND_URL);
|
|
const editorDev = EDITOR_DEV_URL ? new URL(EDITOR_DEV_URL) : null;
|
|
const portalDev = PORTAL_DEV_URL ? new URL(PORTAL_DEV_URL) : null;
|
|
|
|
// Paths owned by the backend (mirrors the editor/portal Vite dev proxies).
|
|
const API_PREFIXES = ["/api", "/oauth2", "/saml2", "/v1/api-docs"];
|
|
|
|
const MIME: Record<string, string> = {
|
|
".html": "text/html; charset=utf-8",
|
|
".js": "text/javascript",
|
|
".mjs": "text/javascript",
|
|
".css": "text/css",
|
|
".json": "application/json",
|
|
".svg": "image/svg+xml",
|
|
".png": "image/png",
|
|
".jpg": "image/jpeg",
|
|
".jpeg": "image/jpeg",
|
|
".gif": "image/gif",
|
|
".webp": "image/webp",
|
|
".ico": "image/x-icon",
|
|
".woff": "font/woff",
|
|
".woff2": "font/woff2",
|
|
".ttf": "font/ttf",
|
|
".wasm": "application/wasm",
|
|
".map": "application/json",
|
|
".txt": "text/plain; charset=utf-8",
|
|
".toml": "text/plain; charset=utf-8",
|
|
};
|
|
|
|
function isApiPath(pathname: string): boolean {
|
|
return API_PREFIXES.some(
|
|
(p) => pathname === p || pathname.startsWith(`${p}/`),
|
|
);
|
|
}
|
|
|
|
function clientFor(target: URL): typeof http | typeof https {
|
|
return target.protocol === "https:" ? https : http;
|
|
}
|
|
|
|
function proxyHttp(
|
|
req: http.IncomingMessage,
|
|
res: http.ServerResponse,
|
|
target: URL,
|
|
label: string,
|
|
): void {
|
|
const upstream = clientFor(target).request(
|
|
{
|
|
protocol: target.protocol,
|
|
hostname: target.hostname,
|
|
port: target.port,
|
|
path: req.url,
|
|
method: req.method,
|
|
headers: { ...req.headers, host: target.host },
|
|
},
|
|
(upRes) => {
|
|
res.writeHead(upRes.statusCode || 502, upRes.headers);
|
|
upRes.pipe(res);
|
|
},
|
|
);
|
|
upstream.on("error", (err: Error) => {
|
|
res.writeHead(502, { "Content-Type": "text/plain" });
|
|
res.end(`${label} not reachable at ${target.origin}: ${err.message}`);
|
|
});
|
|
req.pipe(upstream);
|
|
}
|
|
|
|
async function serveStatic(
|
|
distDir: string,
|
|
urlPath: string,
|
|
res: http.ServerResponse,
|
|
): Promise<void> {
|
|
const pathname = decodeURIComponent((urlPath.split("?")[0] || "/").trim());
|
|
const hasExtension = path.extname(pathname) !== "";
|
|
// Routes (no file extension) and "/" fall back to index.html so the SPA
|
|
// router can take over; real assets resolve to their file.
|
|
const candidate = hasExtension
|
|
? path.join(distDir, pathname)
|
|
: path.join(distDir, "index.html");
|
|
const resolved = path.resolve(candidate);
|
|
|
|
// Path-traversal guard.
|
|
if (resolved !== distDir && !resolved.startsWith(distDir + path.sep)) {
|
|
res.writeHead(403, { "Content-Type": "text/plain" });
|
|
res.end("Forbidden");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const info = await stat(resolved);
|
|
if (!info.isFile()) throw new Error("not a file");
|
|
} catch {
|
|
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
res.end(
|
|
hasExtension
|
|
? "Not found"
|
|
: `index.html not found in ${distDir}. Did the build run? (task dev:portal:proxy builds first)`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
const ext = path.extname(resolved).toLowerCase();
|
|
res.writeHead(200, {
|
|
"Content-Type": MIME[ext] || "application/octet-stream",
|
|
"Cache-Control": "no-store",
|
|
});
|
|
createReadStream(resolved).pipe(res);
|
|
}
|
|
|
|
const server = http.createServer((req, res) => {
|
|
const pathname = (req.url || "/").split("?")[0];
|
|
|
|
if (isApiPath(pathname)) {
|
|
proxyHttp(req, res, backend, "Backend");
|
|
return;
|
|
}
|
|
if (pathname === "/portal" || pathname.startsWith("/portal/")) {
|
|
if (portalDev) {
|
|
// Dev server is served under the /portal base, so keep the full path.
|
|
proxyHttp(req, res, portalDev, "Portal dev server");
|
|
} else {
|
|
const rest = (req.url || "").slice("/portal".length) || "/";
|
|
void serveStatic(PORTAL_DIST, rest, res);
|
|
}
|
|
return;
|
|
}
|
|
if (editorDev) {
|
|
proxyHttp(req, res, editorDev, "Editor dev server");
|
|
} else {
|
|
void serveStatic(EDITOR_DIST, req.url || "/", res);
|
|
}
|
|
});
|
|
|
|
// Forward websocket upgrades to the matching upstream. Backend streaming
|
|
// endpoints need /api; the dev servers' HMR sockets connect directly to their
|
|
// own ports, but routing them here too keeps things working if a browser sends
|
|
// them through the proxy.
|
|
function proxyUpgrade(
|
|
req: http.IncomingMessage,
|
|
socket: Duplex,
|
|
head: Buffer,
|
|
target: URL,
|
|
): void {
|
|
const upstream = clientFor(target).request({
|
|
protocol: target.protocol,
|
|
hostname: target.hostname,
|
|
port: target.port,
|
|
path: req.url,
|
|
method: req.method,
|
|
headers: { ...req.headers, host: target.host },
|
|
});
|
|
upstream.on("upgrade", (upRes, upSocket, upHead) => {
|
|
const headerLines = Object.entries(upRes.headers).map(
|
|
([k, v]) => `${k}: ${v as string}`,
|
|
);
|
|
socket.write(
|
|
`HTTP/1.1 101 Switching Protocols\r\n${headerLines.join("\r\n")}\r\n\r\n`,
|
|
);
|
|
if (upHead && upHead.length) upSocket.unshift(upHead);
|
|
if (head && head.length) upSocket.write(head);
|
|
upSocket.pipe(socket);
|
|
socket.pipe(upSocket);
|
|
upSocket.on("error", () => socket.destroy());
|
|
socket.on("error", () => upSocket.destroy());
|
|
});
|
|
upstream.on("error", () => socket.destroy());
|
|
upstream.end();
|
|
}
|
|
|
|
server.on("upgrade", (req, socket, head) => {
|
|
const pathname = (req.url || "/").split("?")[0];
|
|
if (isApiPath(pathname)) {
|
|
proxyUpgrade(req, socket, head, backend);
|
|
} else if (
|
|
portalDev &&
|
|
(pathname === "/portal" || pathname.startsWith("/portal/"))
|
|
) {
|
|
proxyUpgrade(req, socket, head, portalDev);
|
|
} else if (editorDev) {
|
|
proxyUpgrade(req, socket, head, editorDev);
|
|
} else {
|
|
socket.destroy();
|
|
}
|
|
});
|
|
|
|
server.listen(PORT, () => {
|
|
const editorSrc = editorDev ? `${editorDev.origin} (dev)` : EDITOR_DIST;
|
|
const portalSrc = portalDev ? `${portalDev.origin} (dev)` : PORTAL_DIST;
|
|
console.log("");
|
|
console.log(" Unified-auth single-origin server");
|
|
console.log(` ▶ open http://localhost:${PORT}/ (editor)`);
|
|
console.log(` ▶ open http://localhost:${PORT}/portal (portal)`);
|
|
console.log(` ▶ backend ${BACKEND_URL} (proxying /api, /oauth2, /saml2)`);
|
|
console.log(` ▶ editor ${editorSrc}`);
|
|
console.log(` ▶ portal ${portalSrc}`);
|
|
console.log(
|
|
" Log into one, open the other - the stirling_jwt token is shared (same origin).",
|
|
);
|
|
console.log("");
|
|
});
|