improved a lot of things
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@originmain/cli",
|
||||
"version": "0.0.1",
|
||||
"private": false,
|
||||
"description": "Originmain CLI — reverse proxy for live React component inspection.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"originmain": "./dist/cli.js"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsc -p tsconfig.build.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@originmain/renderer": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// ── Originmain CLI ───────────────────────────────────────────────────────────
|
||||
// Usage:
|
||||
// npx @originmain/cli dev --target http://localhost:3000 [--port 4170]
|
||||
//
|
||||
// Starts a reverse proxy that enables live React component inspection
|
||||
// in Originmain artboards. See RENDERING-ARCHITECTURE.md for details.
|
||||
|
||||
import { parseArgs } from 'node:util';
|
||||
import { startProxy } from './proxy.js';
|
||||
|
||||
const DEFAULT_PORT = 4170;
|
||||
|
||||
function printUsage(): void {
|
||||
console.log(`
|
||||
\x1b[36m\x1b[1mOriginmain CLI\x1b[0m
|
||||
|
||||
Usage:
|
||||
originmain dev --target <url> [--port <number>]
|
||||
|
||||
Options:
|
||||
--target, -t Target dev server URL (required)
|
||||
Example: http://localhost:3000
|
||||
--port, -p Proxy listen port (default: ${DEFAULT_PORT})
|
||||
--help, -h Show this help
|
||||
|
||||
Example:
|
||||
npx @originmain/cli dev --target http://localhost:3000
|
||||
`);
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
// Parse arguments
|
||||
const { values, positionals } = parseArgs({
|
||||
args: process.argv.slice(2),
|
||||
options: {
|
||||
target: { type: 'string', short: 't' },
|
||||
port: { type: 'string', short: 'p' },
|
||||
help: { type: 'boolean', short: 'h' },
|
||||
},
|
||||
allowPositionals: true,
|
||||
strict: false,
|
||||
});
|
||||
|
||||
const command = positionals[0];
|
||||
|
||||
if (values.help || !command) {
|
||||
printUsage();
|
||||
process.exit(command ? 0 : 1);
|
||||
}
|
||||
|
||||
if (command !== 'dev') {
|
||||
console.error(` Unknown command: ${command}\n Run "originmain --help" for usage.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Validate --target
|
||||
const target = values.target;
|
||||
if (!target || typeof target !== 'string') {
|
||||
console.error(' Error: --target is required.\n Example: originmain dev --target http://localhost:3000');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Validate target URL
|
||||
let targetUrl: URL;
|
||||
try {
|
||||
targetUrl = new URL(target);
|
||||
} catch {
|
||||
console.error(` Error: Invalid target URL: ${target}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (targetUrl.protocol !== 'http:' && targetUrl.protocol !== 'https:') {
|
||||
console.error(` Error: Target must be http:// or https:// (got ${targetUrl.protocol})`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Parse port
|
||||
const port = values.port ? parseInt(values.port as string, 10) : DEFAULT_PORT;
|
||||
if (Number.isNaN(port) || port < 1 || port > 65535) {
|
||||
console.error(` Error: Invalid port: ${values.port}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Start proxy
|
||||
const proxy = startProxy({ target, port });
|
||||
|
||||
// Graceful shutdown
|
||||
function shutdown(): void {
|
||||
console.log('\n Shutting down proxy...');
|
||||
proxy.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,4 @@
|
||||
// Programmatic API — allows using the proxy from other Node.js code
|
||||
export { startProxy } from './proxy.js';
|
||||
export type { ProxyOptions } from './proxy.js';
|
||||
export { injectFiberHook } from './inject.js';
|
||||
@@ -0,0 +1,45 @@
|
||||
// ── HTML Injection ───────────────────────────────────────────────────────────
|
||||
// Injects the Originmain fiber hook <script> into an HTML response body.
|
||||
// The script must appear BEFORE any other scripts so that
|
||||
// __REACT_DEVTOOLS_GLOBAL_HOOK__ is installed before React evaluates.
|
||||
|
||||
import { buildProxyFiberHookScript } from '@originmain/renderer';
|
||||
|
||||
/** The fiber hook script wrapped in a <script> tag, generated once at startup. */
|
||||
let cachedScriptTag: string | undefined;
|
||||
|
||||
function getScriptTag(): string {
|
||||
if (cachedScriptTag === undefined) {
|
||||
cachedScriptTag = `<script data-originmain-fiber-hook>${buildProxyFiberHookScript()}</script>`;
|
||||
}
|
||||
return cachedScriptTag;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
export function injectFiberHook(html: string): string {
|
||||
const tag = getScriptTag();
|
||||
|
||||
// Try after <head>
|
||||
const headMatch = /<head[^>]*>/i.exec(html);
|
||||
if (headMatch) {
|
||||
const insertAt = headMatch.index + headMatch[0].length;
|
||||
return html.slice(0, insertAt) + tag + html.slice(insertAt);
|
||||
}
|
||||
|
||||
// Try after <html>
|
||||
const htmlMatch = /<html[^>]*>/i.exec(html);
|
||||
if (htmlMatch) {
|
||||
const insertAt = htmlMatch.index + htmlMatch[0].length;
|
||||
return html.slice(0, insertAt) + tag + html.slice(insertAt);
|
||||
}
|
||||
|
||||
// Final fallback: prepend
|
||||
return tag + html;
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
// ── Reverse Proxy ────────────────────────────────────────────────────────────
|
||||
// HTTP reverse proxy that:
|
||||
// 1. Forwards all requests to the target dev server
|
||||
// 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';
|
||||
|
||||
export interface ProxyOptions {
|
||||
/** Target dev server URL, e.g. "http://localhost:3000" */
|
||||
target: string;
|
||||
/** Port for the proxy to listen on (default: 4170) */
|
||||
port: number;
|
||||
}
|
||||
|
||||
/** Headers to strip from proxied responses (case-insensitive). */
|
||||
const STRIP_RESPONSE_HEADERS = new Set([
|
||||
'x-frame-options',
|
||||
'content-security-policy',
|
||||
'content-security-policy-report-only',
|
||||
]);
|
||||
|
||||
/** CORS headers added to every response for cross-origin API compatibility. */
|
||||
const CORS_HEADERS: Record<string, string> = {
|
||||
'access-control-allow-origin': '*',
|
||||
'access-control-allow-methods': '*',
|
||||
'access-control-allow-headers': '*',
|
||||
'access-control-allow-credentials': 'true',
|
||||
};
|
||||
|
||||
/**
|
||||
* Start the reverse proxy server.
|
||||
* Returns a cleanup function that shuts down the server.
|
||||
*/
|
||||
export function startProxy(opts: ProxyOptions): { close: () => void } {
|
||||
const targetUrl = new URL(opts.target);
|
||||
const targetHost = targetUrl.hostname;
|
||||
const targetPort = parseInt(targetUrl.port || '80', 10);
|
||||
|
||||
const server = createServer((clientReq: IncomingMessage, clientRes: ServerResponse) => {
|
||||
// ── Build the outgoing request to the target ─────────────────────────
|
||||
|
||||
// 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') {
|
||||
outHeaders[key] = `${targetHost}:${targetPort}`;
|
||||
continue;
|
||||
}
|
||||
if (val !== undefined) {
|
||||
outHeaders[key] = val;
|
||||
}
|
||||
}
|
||||
|
||||
const proxyReq = httpRequest(
|
||||
{
|
||||
hostname: targetHost,
|
||||
port: targetPort,
|
||||
path: clientReq.url ?? '/',
|
||||
method: clientReq.method,
|
||||
headers: outHeaders,
|
||||
},
|
||||
(proxyRes) => {
|
||||
// ── Process 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;
|
||||
}
|
||||
}
|
||||
|
||||
// Add CORS headers
|
||||
for (const [key, val] of Object.entries(CORS_HEADERS)) {
|
||||
resHeaders[key] = val;
|
||||
}
|
||||
|
||||
// ── Determine if this is an HTML response ──────────────────────
|
||||
|
||||
const contentType = (proxyRes.headers['content-type'] ?? '').toLowerCase();
|
||||
const isHtml = contentType.includes('text/html');
|
||||
|
||||
if (!isHtml) {
|
||||
// Non-HTML: stream through unchanged (headers already stripped)
|
||||
clientRes.writeHead(proxyRes.statusCode ?? 200, resHeaders);
|
||||
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');
|
||||
|
||||
// Update Content-Length to match the injected body
|
||||
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);
|
||||
clientRes.end(body);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
proxyReq.on('error', (err) => {
|
||||
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}`);
|
||||
});
|
||||
|
||||
// Pipe the client request body to the target
|
||||
clientReq.pipe(proxyReq);
|
||||
});
|
||||
|
||||
// ── 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)
|
||||
.filter(([key]) => key.toLowerCase() !== 'host')
|
||||
.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);
|
||||
}
|
||||
|
||||
// Pipe bidirectionally
|
||||
targetSocket.pipe(clientSocket);
|
||||
clientSocket.pipe(targetSocket);
|
||||
});
|
||||
|
||||
targetSocket.on('error', (err) => {
|
||||
console.error(`[originmain proxy] WebSocket proxy error: ${err.message}`);
|
||||
clientSocket.destroy();
|
||||
});
|
||||
|
||||
clientSocket.on('error', () => {
|
||||
targetSocket.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(opts.port, () => {
|
||||
const proxyUrl = `http://localhost:${opts.port}`;
|
||||
console.log('');
|
||||
console.log(' \x1b[36m\x1b[1mOriginmain\x1b[0m proxy running');
|
||||
console.log('');
|
||||
console.log(` Target: ${opts.target}`);
|
||||
console.log(` Proxy: \x1b[1m${proxyUrl}\x1b[0m`);
|
||||
console.log('');
|
||||
console.log(' Paste the proxy URL into your Originmain artboard\'s');
|
||||
console.log(' "Connect app" field to enable live rendering.');
|
||||
console.log('');
|
||||
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');
|
||||
console.log('');
|
||||
});
|
||||
|
||||
return {
|
||||
close() {
|
||||
server.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": false,
|
||||
"outDir": "dist",
|
||||
"declaration": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user