improved a lot of things
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env node
|
||||
// ── CLI bundle script ─────────────────────────────────────────────────────────
|
||||
// Produces two self-contained ESM bundles under dist/:
|
||||
//
|
||||
// dist/cli.js — the `originmain` binary (has shebang, chmod +x)
|
||||
// dist/index.js — programmatic API: startProxy(), injectFiberHook()
|
||||
//
|
||||
// Run: node build.mjs (or via "pnpm build")
|
||||
|
||||
import { build } from 'esbuild';
|
||||
import { readFileSync, writeFileSync, chmodSync } from 'node:fs';
|
||||
|
||||
// Node.js built-in module names (without and with the node: prefix).
|
||||
// Both forms must be listed so esbuild leaves them as-is whether the
|
||||
// source imports 'http' or 'node:http'.
|
||||
const NODE_BUILTINS = [
|
||||
'node:*',
|
||||
'assert', 'buffer', 'child_process', 'cluster', 'console', 'constants',
|
||||
'crypto', 'dgram', 'dns', 'domain', 'events', 'fs', 'http', 'http2',
|
||||
'https', 'module', 'net', 'os', 'path', 'perf_hooks', 'process',
|
||||
'punycode', 'querystring', 'readline', 'repl', 'stream', 'string_decoder',
|
||||
'sys', 'timers', 'tls', 'trace_events', 'tty', 'url', 'util', 'v8',
|
||||
'vm', 'worker_threads', 'zlib',
|
||||
];
|
||||
|
||||
const SHARED_OPTIONS = {
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'esm',
|
||||
target: 'node22',
|
||||
external: NODE_BUILTINS,
|
||||
logLevel: 'info',
|
||||
};
|
||||
|
||||
// ── Build both entry points in parallel ──────────────────────────────────────
|
||||
|
||||
await Promise.all([
|
||||
build({ ...SHARED_OPTIONS, entryPoints: ['src/cli.ts'], outfile: 'dist/cli.js' }),
|
||||
build({ ...SHARED_OPTIONS, entryPoints: ['src/index.ts'], outfile: 'dist/index.js' }),
|
||||
]);
|
||||
|
||||
// ── Add shebang + executable bit to the CLI binary ───────────────────────────
|
||||
|
||||
const SHEBANG = '#!/usr/bin/env node\n';
|
||||
const cliContent = readFileSync('dist/cli.js', 'utf-8');
|
||||
|
||||
if (!cliContent.startsWith('#!')) {
|
||||
writeFileSync('dist/cli.js', SHEBANG + cliContent, 'utf-8');
|
||||
}
|
||||
chmodSync('dist/cli.js', 0o755);
|
||||
|
||||
console.log(' ✓ dist/cli.js (binary)');
|
||||
console.log(' ✓ dist/index.js (programmatic API)');
|
||||
@@ -8,17 +8,25 @@
|
||||
"originmain": "./dist/cli.js"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist/cli.js",
|
||||
"dist/index.js",
|
||||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsc -p tsconfig.build.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@originmain/renderer": "workspace:*"
|
||||
"build": "node build.mjs"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"@originmain/renderer": "workspace:*",
|
||||
"@types/node": "^22.0.0",
|
||||
"esbuild": "^0.28.0",
|
||||
"typescript": "^5.5.0"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -47,7 +47,8 @@ function main(): void {
|
||||
|
||||
if (values.help || !command) {
|
||||
printUsage();
|
||||
process.exit(command ? 0 : 1);
|
||||
// --help is a success; missing command is a usage error.
|
||||
process.exit(values.help ? 0 : 1);
|
||||
}
|
||||
|
||||
if (command !== 'dev') {
|
||||
|
||||
+30
-10
@@ -15,31 +15,51 @@ function getScriptTag(): string {
|
||||
return cachedScriptTag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip inline Content-Security-Policy meta tags from HTML.
|
||||
*
|
||||
* The proxy already removes the CSP response header, but some frameworks
|
||||
* (e.g. Next.js with a custom _document) also embed CSP inside a meta tag.
|
||||
* A meta CSP applies to the entire document — including scripts parsed before
|
||||
* it — so our injected hook can be silently blocked even though it runs first.
|
||||
* Removing these tags lets the hook execute freely inside the sandboxed iframe.
|
||||
*/
|
||||
function stripMetaCsp(html: string): string {
|
||||
return html.replace(
|
||||
/<meta[^>]+http-equiv\s*=\s*["']?\s*content-security-policy\s*["']?[^>]*\/?>/gi,
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject the fiber hook script into an HTML string.
|
||||
*
|
||||
* Injection strategy (in order of preference):
|
||||
* 1. After `<head...>` — standard position for early scripts
|
||||
* 2. After `<html...>` — fallback if <head> is missing
|
||||
* 3. Prepend to document — final fallback
|
||||
* Steps:
|
||||
* 1. Strip any inline Content-Security-Policy meta tags that could block the
|
||||
* injected script (the CSP response header is stripped by the proxy itself).
|
||||
* 2. Insert the hook script immediately after the opening <head> tag
|
||||
* (preferred), after <html> (fallback), or prepend to the document (final
|
||||
* fallback). Placing it first ensures __REACT_DEVTOOLS_GLOBAL_HOOK__ is
|
||||
* registered before React's module body runs.
|
||||
*/
|
||||
export function injectFiberHook(html: string): string {
|
||||
const tag = getScriptTag();
|
||||
const tag = getScriptTag();
|
||||
const cleaned = stripMetaCsp(html);
|
||||
|
||||
// Try after <head>
|
||||
const headMatch = /<head[^>]*>/i.exec(html);
|
||||
const headMatch = /<head[^>]*>/i.exec(cleaned);
|
||||
if (headMatch) {
|
||||
const insertAt = headMatch.index + headMatch[0].length;
|
||||
return html.slice(0, insertAt) + tag + html.slice(insertAt);
|
||||
return cleaned.slice(0, insertAt) + tag + cleaned.slice(insertAt);
|
||||
}
|
||||
|
||||
// Try after <html>
|
||||
const htmlMatch = /<html[^>]*>/i.exec(html);
|
||||
const htmlMatch = /<html[^>]*>/i.exec(cleaned);
|
||||
if (htmlMatch) {
|
||||
const insertAt = htmlMatch.index + htmlMatch[0].length;
|
||||
return html.slice(0, insertAt) + tag + html.slice(insertAt);
|
||||
return cleaned.slice(0, insertAt) + tag + cleaned.slice(insertAt);
|
||||
}
|
||||
|
||||
// Final fallback: prepend
|
||||
return tag + html;
|
||||
return tag + cleaned;
|
||||
}
|
||||
|
||||
+154
-64
@@ -1,17 +1,20 @@
|
||||
// ── Reverse Proxy ────────────────────────────────────────────────────────────
|
||||
// HTTP reverse proxy that:
|
||||
// 1. Forwards all requests to the target dev server
|
||||
// HTTP(S) reverse proxy that:
|
||||
// 1. Forwards all requests to the target dev server (http or https)
|
||||
// 2. Strips X-Frame-Options and CSP frame-ancestors from responses
|
||||
// 3. Injects the Originmain fiber hook into HTML responses
|
||||
// 4. Passes WebSocket upgrades through for HMR
|
||||
//
|
||||
// Uses only Node.js built-ins — no external dependencies.
|
||||
|
||||
import { createServer, request as httpRequest } from 'node:http';
|
||||
import { connect as netConnect } from 'node:net';
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import type { Socket } from 'node:net';
|
||||
import { injectFiberHook } from './inject.js';
|
||||
import { createServer } from 'node:http';
|
||||
import { request as httpRequest } from 'node:http';
|
||||
import { request as httpsRequest } from 'node:https';
|
||||
import { connect as netConnect } from 'node:net';
|
||||
import type { IncomingMessage, ServerResponse,
|
||||
RequestOptions, ClientRequest } from 'node:http';
|
||||
import type { Socket } from 'node:net';
|
||||
import { injectFiberHook } from './inject.js';
|
||||
|
||||
export interface ProxyOptions {
|
||||
/** Target dev server URL, e.g. "http://localhost:3000" */
|
||||
@@ -27,88 +30,141 @@ const STRIP_RESPONSE_HEADERS = new Set([
|
||||
'content-security-policy-report-only',
|
||||
]);
|
||||
|
||||
/** CORS headers added to every response for cross-origin API compatibility. */
|
||||
/** CORS headers appended to every response so the canvas can reach the app. */
|
||||
const CORS_HEADERS: Record<string, string> = {
|
||||
'access-control-allow-origin': '*',
|
||||
'access-control-allow-methods': '*',
|
||||
'access-control-allow-headers': '*',
|
||||
'access-control-allow-origin': '*',
|
||||
'access-control-allow-methods': '*',
|
||||
'access-control-allow-headers': '*',
|
||||
'access-control-allow-credentials': 'true',
|
||||
};
|
||||
|
||||
// ── Outgoing request helper ────────────────────────────────────────────────
|
||||
// Chooses http / https based on the target protocol.
|
||||
// rejectUnauthorized is disabled for HTTPS because dev / staging servers
|
||||
// frequently use self-signed certificates.
|
||||
|
||||
function doRequest(
|
||||
isHttps: boolean,
|
||||
options: RequestOptions,
|
||||
cb: (res: IncomingMessage) => void,
|
||||
): ClientRequest {
|
||||
if (isHttps) {
|
||||
return httpsRequest({ ...options, rejectUnauthorized: false }, cb);
|
||||
}
|
||||
return httpRequest(options, cb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the reverse proxy server.
|
||||
* Returns a cleanup function that shuts down the server.
|
||||
* Returns an object with a `close()` method for graceful shutdown.
|
||||
*/
|
||||
export function startProxy(opts: ProxyOptions): { close: () => void } {
|
||||
const targetUrl = new URL(opts.target);
|
||||
const targetUrl = new URL(opts.target);
|
||||
const isHttps = targetUrl.protocol === 'https:';
|
||||
const targetHost = targetUrl.hostname;
|
||||
const targetPort = parseInt(targetUrl.port || '80', 10);
|
||||
// Default port: 443 for HTTPS, 80 for HTTP — matches browser behaviour.
|
||||
const targetPort = parseInt(targetUrl.port || (isHttps ? '443' : '80'), 10);
|
||||
|
||||
const server = createServer((clientReq: IncomingMessage, clientRes: ServerResponse) => {
|
||||
// ── Build the outgoing request to the target ─────────────────────────
|
||||
// ── Build the outgoing request headers ────────────────────────────────
|
||||
|
||||
// Clone headers, stripping Accept-Encoding so the target sends
|
||||
// uncompressed HTML (avoids needing to decompress before injection).
|
||||
const outHeaders: Record<string, string | string[]> = {};
|
||||
|
||||
for (const [key, val] of Object.entries(clientReq.headers)) {
|
||||
if (key.toLowerCase() === 'accept-encoding') continue;
|
||||
if (key.toLowerCase() === 'host') {
|
||||
const lower = key.toLowerCase();
|
||||
|
||||
// Strip Accept-Encoding — we replace it with 'identity' below so the
|
||||
// target always returns uncompressed HTML that we can decode and inject.
|
||||
if (lower === 'accept-encoding') continue;
|
||||
|
||||
// Rewrite Host to point at the actual target, not the proxy.
|
||||
if (lower === 'host') {
|
||||
outHeaders[key] = `${targetHost}:${targetPort}`;
|
||||
continue;
|
||||
}
|
||||
if (val !== undefined) {
|
||||
outHeaders[key] = val;
|
||||
}
|
||||
|
||||
if (val !== undefined) outHeaders[key] = val as string | string[];
|
||||
}
|
||||
|
||||
const proxyReq = httpRequest(
|
||||
// Explicitly ask for uncompressed content.
|
||||
outHeaders['accept-encoding'] = 'identity';
|
||||
|
||||
const method = clientReq.method ?? 'GET';
|
||||
const isHeadReq = method === 'HEAD';
|
||||
|
||||
const proxyReq = doRequest(
|
||||
isHttps,
|
||||
{
|
||||
hostname: targetHost,
|
||||
port: targetPort,
|
||||
path: clientReq.url ?? '/',
|
||||
method: clientReq.method,
|
||||
headers: outHeaders,
|
||||
port: targetPort,
|
||||
path: clientReq.url ?? '/',
|
||||
method,
|
||||
headers: outHeaders,
|
||||
},
|
||||
(proxyRes) => {
|
||||
// ── Process response headers ───────────────────────────────────
|
||||
(proxyRes: IncomingMessage) => {
|
||||
// ── Build clean response headers ──────────────────────────────────
|
||||
|
||||
const resHeaders: Record<string, string | string[]> = {};
|
||||
|
||||
for (const [key, val] of Object.entries(proxyRes.headers)) {
|
||||
if (STRIP_RESPONSE_HEADERS.has(key.toLowerCase())) continue;
|
||||
if (val !== undefined) {
|
||||
resHeaders[key] = val;
|
||||
}
|
||||
if (val !== undefined) resHeaders[key] = val as string | string[];
|
||||
}
|
||||
|
||||
// Add CORS headers
|
||||
// Append CORS.
|
||||
for (const [key, val] of Object.entries(CORS_HEADERS)) {
|
||||
resHeaders[key] = val;
|
||||
}
|
||||
|
||||
// ── Determine if this is an HTML response ──────────────────────
|
||||
// ── Detect HTML responses ─────────────────────────────────────────
|
||||
|
||||
const contentType = (proxyRes.headers['content-type'] ?? '').toLowerCase();
|
||||
const isHtml = contentType.includes('text/html');
|
||||
const isHtml = contentType.includes('text/html');
|
||||
|
||||
if (!isHtml) {
|
||||
// Non-HTML: stream through unchanged (headers already stripped)
|
||||
// HEAD and non-HTML responses: stream (or drain) through unchanged.
|
||||
// HEAD: RFC 7231 §4.3.2 — response MUST NOT include a body.
|
||||
// We must drain the response to free the socket, but send no body.
|
||||
// Non-HTML: no injection needed, stream straight through.
|
||||
if (isHeadReq || !isHtml) {
|
||||
clientRes.writeHead(proxyRes.statusCode ?? 200, resHeaders);
|
||||
proxyRes.pipe(clientRes);
|
||||
proxyRes.on('error', (err: Error) => {
|
||||
console.error(`[originmain proxy] Response stream error: ${err.message}`);
|
||||
clientRes.destroy();
|
||||
});
|
||||
if (isHeadReq) {
|
||||
// Drain + end without sending body.
|
||||
proxyRes.resume();
|
||||
proxyRes.on('end', () => clientRes.end());
|
||||
} else {
|
||||
proxyRes.pipe(clientRes);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// HTML: buffer the full body, inject the script, then send.
|
||||
const chunks: Buffer[] = [];
|
||||
proxyRes.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
proxyRes.on('end', () => {
|
||||
const rawHtml = Buffer.concat(chunks).toString('utf-8');
|
||||
const injectedHtml = injectFiberHook(rawHtml);
|
||||
const body = Buffer.from(injectedHtml, 'utf-8');
|
||||
// HTML GET/POST: buffer, inject the fiber hook script, then send.
|
||||
// Strip Content-Encoding — we decode to UTF-8, so the encoding header
|
||||
// would be incorrect after injection.
|
||||
delete resHeaders['content-encoding'];
|
||||
|
||||
// Update Content-Length to match the injected body
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
proxyRes.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
|
||||
proxyRes.on('error', (err: Error) => {
|
||||
console.error(`[originmain proxy] HTML response stream error: ${err.message}`);
|
||||
if (!clientRes.headersSent) {
|
||||
clientRes.writeHead(502, { 'content-type': 'text/plain' });
|
||||
}
|
||||
clientRes.destroy();
|
||||
});
|
||||
|
||||
proxyRes.on('end', () => {
|
||||
const rawHtml = Buffer.concat(chunks).toString('utf-8');
|
||||
const injectedHtml = injectFiberHook(rawHtml);
|
||||
const body = Buffer.from(injectedHtml, 'utf-8');
|
||||
|
||||
// Correct Content-Length and drop Transfer-Encoding: chunked.
|
||||
resHeaders['content-length'] = String(body.byteLength);
|
||||
// Remove Transfer-Encoding: chunked since we send the full body
|
||||
delete resHeaders['transfer-encoding'];
|
||||
|
||||
clientRes.writeHead(proxyRes.statusCode ?? 200, resHeaders);
|
||||
@@ -117,51 +173,82 @@ export function startProxy(opts: ProxyOptions): { close: () => void } {
|
||||
},
|
||||
);
|
||||
|
||||
proxyReq.on('error', (err) => {
|
||||
// ── Handle target-unreachable errors ──────────────────────────────────
|
||||
|
||||
proxyReq.on('error', (err: Error) => {
|
||||
console.error(`[originmain proxy] Target request failed: ${err.message}`);
|
||||
if (!clientRes.headersSent) {
|
||||
clientRes.writeHead(502, { 'content-type': 'text/plain' });
|
||||
clientRes.end(
|
||||
`Originmain proxy: could not reach target at ${opts.target}\n${err.message}`,
|
||||
);
|
||||
} else {
|
||||
// Headers already on the wire — tear down the connection cleanly.
|
||||
clientRes.destroy();
|
||||
}
|
||||
clientRes.end(`Originmain proxy: could not reach target at ${opts.target}\n${err.message}`);
|
||||
});
|
||||
|
||||
// Pipe the client request body to the target
|
||||
// ── Handle client disconnects ─────────────────────────────────────────
|
||||
|
||||
clientReq.on('error', (err: Error) => {
|
||||
console.error(`[originmain proxy] Client request error: ${err.message}`);
|
||||
proxyReq.destroy();
|
||||
});
|
||||
|
||||
// Pipe the request body (relevant for POST / PUT / PATCH).
|
||||
clientReq.pipe(proxyReq);
|
||||
});
|
||||
|
||||
// ── WebSocket upgrade passthrough (for HMR) ─────────────────────────────
|
||||
// ── Friendly error for port conflicts ─────────────────────────────────────
|
||||
|
||||
server.on('error', (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
console.error(`\n \x1b[31mError:\x1b[0m Port ${opts.port} is already in use.`);
|
||||
console.error(
|
||||
` Try a different port: originmain dev --target ${opts.target} --port ${opts.port + 1}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
|
||||
// ── WebSocket upgrade passthrough (for HMR) ───────────────────────────────
|
||||
|
||||
server.on('upgrade', (req: IncomingMessage, clientSocket: Socket, head: Buffer) => {
|
||||
// Open a TCP connection to the target and forward the HTTP upgrade
|
||||
const targetSocket = netConnect(targetPort, targetHost, () => {
|
||||
// Reconstruct the raw HTTP upgrade request
|
||||
const reqLine = `${req.method} ${req.url} HTTP/${req.httpVersion}\r\n`;
|
||||
const headers = Object.entries(req.headers)
|
||||
// Reconstruct the raw HTTP upgrade request.
|
||||
const reqLine = `${req.method ?? 'GET'} ${req.url ?? '/'} HTTP/${req.httpVersion}\r\n`;
|
||||
const hostHdr = `host: ${targetHost}:${targetPort}`;
|
||||
const otherHdr = Object.entries(req.headers)
|
||||
.filter(([key]) => key.toLowerCase() !== 'host')
|
||||
.map(([key, val]) => `${key}: ${Array.isArray(val) ? val.join(', ') : val ?? ''}`)
|
||||
.map(([key, val]) => `${key}: ${Array.isArray(val) ? val.join(', ') : (val ?? '')}`)
|
||||
.join('\r\n');
|
||||
const hostHeader = `host: ${targetHost}:${targetPort}`;
|
||||
|
||||
targetSocket.write(`${reqLine}${hostHeader}\r\n${headers}\r\n\r\n`);
|
||||
if (head.length > 0) {
|
||||
targetSocket.write(head);
|
||||
}
|
||||
targetSocket.write(`${reqLine}${hostHdr}\r\n${otherHdr}\r\n\r\n`);
|
||||
if (head.length > 0) targetSocket.write(head);
|
||||
|
||||
// Pipe bidirectionally
|
||||
// Bidirectional pipe.
|
||||
targetSocket.pipe(clientSocket);
|
||||
clientSocket.pipe(targetSocket);
|
||||
});
|
||||
|
||||
targetSocket.on('error', (err) => {
|
||||
// Ensure both sockets are torn down when either side closes.
|
||||
targetSocket.on('close', () => clientSocket.destroy());
|
||||
clientSocket.on('close', () => targetSocket.destroy());
|
||||
|
||||
targetSocket.on('error', (err: Error) => {
|
||||
console.error(`[originmain proxy] WebSocket proxy error: ${err.message}`);
|
||||
clientSocket.destroy();
|
||||
});
|
||||
|
||||
clientSocket.on('error', () => {
|
||||
clientSocket.on('error', (err: Error) => {
|
||||
console.error(`[originmain proxy] WebSocket client error: ${err.message}`);
|
||||
targetSocket.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Start listening ───────────────────────────────────────────────────────
|
||||
|
||||
server.listen(opts.port, () => {
|
||||
const proxyUrl = `http://localhost:${opts.port}`;
|
||||
console.log('');
|
||||
@@ -176,6 +263,9 @@ export function startProxy(opts: ProxyOptions): { close: () => void } {
|
||||
console.log(' \x1b[2mFiber hook injection ........ active\x1b[0m');
|
||||
console.log(' \x1b[2mX-Frame-Options stripping ... active\x1b[0m');
|
||||
console.log(' \x1b[2mWebSocket passthrough ....... active\x1b[0m');
|
||||
if (isHttps) {
|
||||
console.log(' \x1b[2mHTTPS → HTTP bridge ......... active\x1b[0m');
|
||||
}
|
||||
console.log('');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user