made tiny updates
This commit is contained in:
@@ -22,7 +22,10 @@
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "node build.mjs"
|
||||
},
|
||||
"dependencies": {},
|
||||
"dependencies": {
|
||||
"chokidar": "^5.0.0",
|
||||
"tinyglobby": "^0.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@originmain/renderer": "workspace:*",
|
||||
"@types/node": "^22.0.0",
|
||||
@@ -30,7 +33,7 @@
|
||||
"typescript": "^5.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"node": ">=18"
|
||||
},
|
||||
"license": "MIT",
|
||||
"publishConfig": {
|
||||
|
||||
+88
-28
@@ -3,41 +3,54 @@
|
||||
// ── Originmain CLI ───────────────────────────────────────────────────────────
|
||||
// Usage:
|
||||
// npx @originmain/cli dev --target http://localhost:3000 [--port 4170]
|
||||
// [--index-port 4171] [--no-index]
|
||||
//
|
||||
// Starts a reverse proxy that enables live React component inspection
|
||||
// in Originmain artboards. See RENDERING-ARCHITECTURE.md for details.
|
||||
// Starts a reverse proxy + optional AST indexer for live React component
|
||||
// inspection in Originmain artboards. See SOURCE-AWARE-CANVAS.md for details.
|
||||
|
||||
import { parseArgs } from 'node:util';
|
||||
import { startProxy } from './proxy.js';
|
||||
import { parseArgs } from 'node:util';
|
||||
import { resolve } from 'node:path';
|
||||
import { startProxy } from './proxy.js';
|
||||
import { Indexer } from './indexer.js';
|
||||
import { startIndexServer } from './index-server.js';
|
||||
import { detectProjectMeta } from './detect-framework.js';
|
||||
|
||||
const DEFAULT_PORT = 4170;
|
||||
const DEFAULT_PORT = 4170;
|
||||
const DEFAULT_INDEX_PORT = 4171;
|
||||
|
||||
function printUsage(): void {
|
||||
console.log(`
|
||||
\x1b[36m\x1b[1mOriginmain CLI\x1b[0m
|
||||
|
||||
Usage:
|
||||
originmain dev --target <url> [--port <number>]
|
||||
originmain dev --target <url> [options]
|
||||
|
||||
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
|
||||
--target, -t Target dev server URL (required)
|
||||
Example: http://localhost:3000
|
||||
--port, -p Proxy listen port (default: ${DEFAULT_PORT})
|
||||
--index-port AST indexer API port (default: ${DEFAULT_INDEX_PORT})
|
||||
--no-index Disable the AST indexer (Props/Code tabs degraded)
|
||||
--help, -h Show this help
|
||||
|
||||
Example:
|
||||
Environment variables:
|
||||
ORIGINMAIN_BRIDGE_URL Agent Bridge URL (default: http://localhost:4172)
|
||||
|
||||
Examples:
|
||||
npx @originmain/cli dev --target http://localhost:3000
|
||||
npx @originmain/cli dev --target http://localhost:3000 --no-index
|
||||
`);
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
// Parse arguments
|
||||
async function main(): Promise<void> {
|
||||
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' },
|
||||
target: { type: 'string', short: 't' },
|
||||
port: { type: 'string', short: 'p' },
|
||||
'index-port': { type: 'string' },
|
||||
'no-index': { type: 'boolean' },
|
||||
help: { type: 'boolean', short: 'h' },
|
||||
},
|
||||
allowPositionals: true,
|
||||
strict: false,
|
||||
@@ -47,7 +60,6 @@ function main(): void {
|
||||
|
||||
if (values.help || !command) {
|
||||
printUsage();
|
||||
// --help is a success; missing command is a usage error.
|
||||
process.exit(values.help ? 0 : 1);
|
||||
}
|
||||
|
||||
@@ -56,18 +68,16 @@ function main(): void {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Validate --target
|
||||
// ── 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 {
|
||||
try { targetUrl = new URL(target); }
|
||||
catch {
|
||||
console.error(` Error: Invalid target URL: ${target}`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -77,20 +87,67 @@ function main(): void {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Parse port
|
||||
// ── Parse ports ───────────────────────────────────────────────────────────
|
||||
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 });
|
||||
const indexPortRaw = values['index-port'] as string | undefined;
|
||||
const indexPort = indexPortRaw ? parseInt(indexPortRaw, 10) : DEFAULT_INDEX_PORT;
|
||||
if (Number.isNaN(indexPort) || indexPort < 1 || indexPort > 65535) {
|
||||
console.error(` Error: Invalid index port: ${indexPortRaw}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
const noIndex = values['no-index'] === true;
|
||||
|
||||
// ── Agent Bridge URL ──────────────────────────────────────────────────────
|
||||
let bridgeUrl = process.env['ORIGINMAIN_BRIDGE_URL'];
|
||||
if (!bridgeUrl) {
|
||||
// Try ~/.originmain/config.json
|
||||
try {
|
||||
const homeDir = process.env['HOME'] ?? process.env['USERPROFILE'] ?? '';
|
||||
const cfgPath = resolve(homeDir, '.originmain', 'config.json');
|
||||
const { readFileSync } = await import('node:fs');
|
||||
const cfg = JSON.parse(readFileSync(cfgPath, 'utf-8')) as Record<string, unknown>;
|
||||
if (typeof cfg['bridgeUrl'] === 'string') bridgeUrl = cfg['bridgeUrl'];
|
||||
} catch { /* config not present */ }
|
||||
|
||||
if (!bridgeUrl) {
|
||||
bridgeUrl = 'http://localhost:4172';
|
||||
console.log(` \x1b[2mUsing default Agent Bridge URL: ${bridgeUrl}\x1b[0m`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Start AST indexer (unless --no-index) ─────────────────────────────────
|
||||
const projectRoot = process.cwd();
|
||||
let indexServer: { close: () => void } | null = null;
|
||||
|
||||
if (!noIndex) {
|
||||
const projectMeta = await detectProjectMeta(projectRoot);
|
||||
const indexer = new Indexer(projectRoot);
|
||||
|
||||
// Start watching in background (non-blocking)
|
||||
indexer.watch().catch((err: Error) => {
|
||||
console.error(`[originmain indexer] Watch error: ${err.message}`);
|
||||
});
|
||||
|
||||
indexServer = startIndexServer({ indexer, projectMeta, projectRoot, port: indexPort });
|
||||
} else {
|
||||
console.log(' \x1b[2mAST indexer disabled (--no-index)\x1b[0m');
|
||||
}
|
||||
|
||||
// ── Start the reverse proxy ───────────────────────────────────────────────
|
||||
const indexUrl = noIndex ? null : `http://localhost:${indexPort}`;
|
||||
const proxy = startProxy({ target, port, indexUrl });
|
||||
|
||||
// ── Graceful shutdown ─────────────────────────────────────────────────────
|
||||
function shutdown(): void {
|
||||
console.log('\n Shutting down proxy...');
|
||||
console.log('\n Shutting down...');
|
||||
proxy.close();
|
||||
indexServer?.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
@@ -98,4 +155,7 @@ function main(): void {
|
||||
process.on('SIGTERM', shutdown);
|
||||
}
|
||||
|
||||
main();
|
||||
main().catch((err: unknown) => {
|
||||
console.error(' Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
// ── Framework & CSS Strategy Detection ───────────────────────────────────────
|
||||
// Canonical detection logic for the Originmain CLI — used by indexer.ts,
|
||||
// index-server.ts, and isolation-server.ts. Import from here; do not duplicate.
|
||||
//
|
||||
// Detection is best-effort and parse-only (no module resolution).
|
||||
// Priority: explicit config files > package.json dependency names.
|
||||
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { glob } from 'tinyglobby';
|
||||
|
||||
export type Framework = 'next' | 'vite' | 'remix' | 'generic';
|
||||
|
||||
export interface ProjectMeta {
|
||||
framework: Framework;
|
||||
tailwind: boolean;
|
||||
cssModules: boolean;
|
||||
styledComponents: boolean;
|
||||
}
|
||||
|
||||
/** Read and JSON-parse package.json from projectRoot, returning {} on any error. */
|
||||
function readPackageJson(projectRoot: string): Record<string, unknown> {
|
||||
try {
|
||||
const raw = readFileSync(join(projectRoot, 'package.json'), 'utf-8');
|
||||
return JSON.parse(raw) as Record<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** Return true if any of the given patterns exist as a file in projectRoot. */
|
||||
function anyFileExists(projectRoot: string, patterns: string[]): boolean {
|
||||
return patterns.some((p) => existsSync(join(projectRoot, p)));
|
||||
}
|
||||
|
||||
/** Return true if name appears in the combined deps+devDeps of the package. */
|
||||
function hasDep(pkg: Record<string, unknown>, ...names: string[]): boolean {
|
||||
const deps = (pkg['dependencies'] ?? {}) as Record<string, unknown>;
|
||||
const devDeps = (pkg['devDependencies'] ?? {}) as Record<string, unknown>;
|
||||
return names.some((n) => n in deps || n in devDeps);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the JS framework used in projectRoot.
|
||||
*
|
||||
* Priority order:
|
||||
* 1. `next` in package.json dependencies → 'next'
|
||||
* 2. remix.config.* in root → 'remix'
|
||||
* 3. vite.config.* in root → 'vite'
|
||||
* 4. `vite` in devDependencies → 'vite'
|
||||
* 5. fallback → 'generic'
|
||||
*/
|
||||
export function detectFramework(projectRoot: string): Framework {
|
||||
const pkg = readPackageJson(projectRoot);
|
||||
|
||||
if (hasDep(pkg, 'next')) return 'next';
|
||||
|
||||
if (anyFileExists(projectRoot, [
|
||||
'remix.config.js', 'remix.config.ts', 'remix.config.mjs', 'remix.config.cjs',
|
||||
])) return 'remix';
|
||||
|
||||
if (anyFileExists(projectRoot, [
|
||||
'vite.config.js', 'vite.config.ts', 'vite.config.mjs', 'vite.config.cjs',
|
||||
])) return 'vite';
|
||||
|
||||
if (hasDep(pkg, 'vite')) return 'vite';
|
||||
|
||||
return 'generic';
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect CSS strategy flags:
|
||||
* tailwind — tailwind.config.* exists OR 'tailwindcss' in deps
|
||||
* cssModules — any *.module.css exists in projectRoot (checked via glob)
|
||||
* styledComponents — 'styled-components' OR '@emotion/react' in deps
|
||||
*
|
||||
* `cssModules` detection uses a glob walk and may be slow on large projects;
|
||||
* callers should invoke this once at startup and cache the result.
|
||||
*/
|
||||
export async function detectCssStrategy(projectRoot: string): Promise<{
|
||||
tailwind: boolean;
|
||||
cssModules: boolean;
|
||||
styledComponents: boolean;
|
||||
}> {
|
||||
const pkg = readPackageJson(projectRoot);
|
||||
|
||||
const tailwind = hasDep(pkg, 'tailwindcss') || anyFileExists(projectRoot, [
|
||||
'tailwind.config.js', 'tailwind.config.ts', 'tailwind.config.mjs', 'tailwind.config.cjs',
|
||||
]);
|
||||
|
||||
const styledComponents = hasDep(
|
||||
pkg,
|
||||
'styled-components', '@emotion/react', '@emotion/styled',
|
||||
);
|
||||
|
||||
// Glob walk for *.module.css — skip node_modules. Uses tinyglobby for
|
||||
// cross-platform support on Node 20 LTS (node:fs/promises glob is Node 22+).
|
||||
let cssModules = false;
|
||||
try {
|
||||
const matches = await glob('**/*.module.css', {
|
||||
cwd: projectRoot,
|
||||
ignore: ['**/node_modules/**', '**/.git/**'],
|
||||
onlyFiles: true,
|
||||
// Stop early: we only need one match.
|
||||
// tinyglobby doesn't have a native limit, but the search is fast enough.
|
||||
});
|
||||
cssModules = matches.length > 0;
|
||||
} catch {
|
||||
cssModules = false;
|
||||
}
|
||||
|
||||
return { tailwind, cssModules, styledComponents };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect all project metadata in one call.
|
||||
* Returns synchronously for framework; async for CSS strategy.
|
||||
*/
|
||||
export async function detectProjectMeta(projectRoot: string): Promise<ProjectMeta> {
|
||||
const framework = detectFramework(projectRoot);
|
||||
const css = await detectCssStrategy(projectRoot);
|
||||
return { framework, ...css };
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
// ── AST Index HTTP Server ─────────────────────────────────────────────────────
|
||||
// Exposes the component index built by the Indexer class over a local HTTP API.
|
||||
// Runs on port 4171 (or --index-port N). Localhost-only; no auth required.
|
||||
//
|
||||
// Endpoints:
|
||||
// GET /health → status + indexed count + projectMeta
|
||||
// GET /components → ComponentEntry[] (all)
|
||||
// GET /components?name=Card → ComponentEntry[] (fuzzy by name)
|
||||
// GET /components?file=src/… → ComponentEntry[] (by file path)
|
||||
// GET /file?path=src/… → { content, lines } (with path security)
|
||||
// GET /events → text/event-stream (SSE for index updates)
|
||||
// POST /reindex → trigger full rescan
|
||||
|
||||
import { createServer } from 'node:http';
|
||||
import { readFileSync, realpathSync, existsSync } from 'node:fs';
|
||||
import { resolve, sep } from 'node:path';
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import type { Indexer } from './indexer.js';
|
||||
import type { ProjectMeta } from './detect-framework.js';
|
||||
|
||||
// ── File security config ────────────────────────────────────────────────────
|
||||
|
||||
/** Only these extensions are allowed through GET /file. */
|
||||
const ALLOWED_EXTENSIONS = new Set([
|
||||
'.ts', '.tsx', '.js', '.jsx', '.css', '.scss', '.json',
|
||||
]);
|
||||
|
||||
/** Path patterns that are always blocked, regardless of extension. */
|
||||
const BLOCKED_PATH_RE = /(\/(\.git|node_modules)\/)|(password|secret|credential|token|private)/i;
|
||||
|
||||
/** Filename patterns that are never served. */
|
||||
const BLOCKED_FILENAME_RE = /^\.env(\.|$)|(\.pem|\.key|\.p12|\.pfx|\.jks|\.crt|\.cer|\.der|\.secret|\.secrets)$/i;
|
||||
|
||||
function isPathSafe(
|
||||
projectRoot: string,
|
||||
requestedPath: string,
|
||||
): { safe: boolean; absolute: string } {
|
||||
// Step 1: URL-decode
|
||||
let decoded: string;
|
||||
try { decoded = decodeURIComponent(requestedPath); }
|
||||
catch { return { safe: false, absolute: '' }; }
|
||||
|
||||
// Step 2: Resolve to absolute (lexical — does NOT follow symlinks yet)
|
||||
const absolute = resolve(projectRoot, decoded);
|
||||
|
||||
// Step 3: Must be within projectRoot (+ sep prevents prefix confusion).
|
||||
// Check lexical path first as a fast pre-filter.
|
||||
if (!absolute.startsWith(projectRoot + sep)) {
|
||||
return { safe: false, absolute };
|
||||
}
|
||||
|
||||
// C-2 fix: resolve() is lexical; a symlink inside the project root could
|
||||
// point outside it (e.g., src/secrets -> /etc). Call realpathSync on the
|
||||
// *parent directory* (which must exist) to canonicalise without requiring
|
||||
// the file itself to exist yet, then re-apply the prefix check.
|
||||
try {
|
||||
if (existsSync(absolute)) {
|
||||
const real = realpathSync(absolute);
|
||||
if (!real.startsWith(projectRoot + sep)) {
|
||||
return { safe: false, absolute };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// realpathSync can fail for broken symlinks — treat as unsafe.
|
||||
return { safe: false, absolute };
|
||||
}
|
||||
|
||||
// Step 4: Extension check
|
||||
const lastDot = absolute.lastIndexOf('.');
|
||||
const ext = lastDot >= 0 ? absolute.slice(lastDot) : '';
|
||||
if (!ALLOWED_EXTENSIONS.has(ext)) return { safe: false, absolute };
|
||||
|
||||
// Step 5: Blocked path / filename patterns
|
||||
if (BLOCKED_PATH_RE.test(absolute)) return { safe: false, absolute };
|
||||
const fileName = absolute.slice(absolute.lastIndexOf(sep) + 1);
|
||||
if (BLOCKED_FILENAME_RE.test(fileName)) return { safe: false, absolute };
|
||||
|
||||
return { safe: true, absolute };
|
||||
}
|
||||
|
||||
// ── CORS headers ────────────────────────────────────────────────────────────
|
||||
|
||||
const CORS: Record<string, string> = {
|
||||
'access-control-allow-origin': '*',
|
||||
'access-control-allow-methods': 'GET, POST, OPTIONS',
|
||||
'access-control-allow-headers': 'content-type',
|
||||
};
|
||||
|
||||
function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
||||
const json = JSON.stringify(body);
|
||||
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', ...CORS });
|
||||
res.end(json);
|
||||
}
|
||||
|
||||
function sendError(res: ServerResponse, status: number, message: string): void {
|
||||
sendJson(res, status, { error: message });
|
||||
}
|
||||
|
||||
// ── SSE helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Active SSE connections; used to broadcast index update events. */
|
||||
let sseClients: ServerResponse[] = [];
|
||||
|
||||
function broadcastSse(event: Record<string, unknown>): void {
|
||||
const data = `data: ${JSON.stringify(event)}\n\n`;
|
||||
sseClients = sseClients.filter((res) => {
|
||||
try { res.write(data); return true; }
|
||||
catch { return false; }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the index HTTP server.
|
||||
* Returns a `close()` method for graceful shutdown.
|
||||
*/
|
||||
export function startIndexServer(opts: {
|
||||
indexer: Indexer;
|
||||
projectMeta: ProjectMeta;
|
||||
projectRoot: string;
|
||||
port: number;
|
||||
}): { close: () => void } {
|
||||
const { indexer, projectMeta, projectRoot, port } = opts;
|
||||
|
||||
// Broadcast index updates to SSE clients
|
||||
indexer.on('update', (event: Record<string, unknown>) => {
|
||||
broadcastSse({ type: 'INDEX_UPDATED', ...event });
|
||||
});
|
||||
|
||||
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
|
||||
const url = new URL(req.url ?? '/', `http://localhost:${port}`);
|
||||
const path = url.pathname;
|
||||
const method = req.method?.toUpperCase() ?? 'GET';
|
||||
|
||||
// CORS preflight
|
||||
if (method === 'OPTIONS') {
|
||||
res.writeHead(204, CORS);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// ── GET /health ────────────────────────────────────────────────────────
|
||||
if (path === '/health' && method === 'GET') {
|
||||
sendJson(res, 200, {
|
||||
status: indexer.status,
|
||||
indexed: indexer.componentCount(),
|
||||
lastScan: indexer.lastScan,
|
||||
projectRoot,
|
||||
projectMeta,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// ── GET /components ────────────────────────────────────────────────────
|
||||
if (path === '/components' && method === 'GET') {
|
||||
const nameParam = url.searchParams.get('name');
|
||||
const fileParam = url.searchParams.get('file');
|
||||
const results = indexer.query({
|
||||
...(nameParam != null && { name: nameParam }),
|
||||
...(fileParam != null && { file: fileParam }),
|
||||
});
|
||||
sendJson(res, 200, results);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── GET /file ──────────────────────────────────────────────────────────
|
||||
if (path === '/file' && method === 'GET') {
|
||||
const requestedPath = url.searchParams.get('path') ?? '';
|
||||
console.log(`[originmain indexer] File request: ${requestedPath}`);
|
||||
|
||||
if (!requestedPath) {
|
||||
sendError(res, 400, 'Missing ?path= parameter');
|
||||
return;
|
||||
}
|
||||
|
||||
const { safe, absolute } = isPathSafe(projectRoot, requestedPath);
|
||||
if (!safe) {
|
||||
console.warn(`[originmain indexer] Blocked file request: ${requestedPath} (resolved: ${absolute})`);
|
||||
sendError(res, 403, 'Access denied');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(absolute, 'utf-8');
|
||||
const lines = content.split('\n').length;
|
||||
sendJson(res, 200, { content, lines });
|
||||
} catch {
|
||||
sendError(res, 404, 'File not found');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ── GET /events (SSE) ──────────────────────────────────────────────────
|
||||
if (path === '/events' && method === 'GET') {
|
||||
res.writeHead(200, {
|
||||
'content-type': 'text/event-stream',
|
||||
'cache-control': 'no-cache',
|
||||
'connection': 'keep-alive',
|
||||
...CORS,
|
||||
});
|
||||
// Heartbeat comment to keep connection alive through proxies
|
||||
res.write(': connected\n\n');
|
||||
sseClients.push(res);
|
||||
|
||||
// Send current status immediately on connect
|
||||
res.write(`data: ${JSON.stringify({ type: 'STATUS', status: indexer.status, indexed: indexer.componentCount() })}\n\n`);
|
||||
|
||||
req.on('close', () => {
|
||||
sseClients = sseClients.filter((c) => c !== res);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// ── POST /reindex ──────────────────────────────────────────────────────
|
||||
if (path === '/reindex' && method === 'POST') {
|
||||
indexer.fullScan().catch((err: Error) => {
|
||||
console.error(`[originmain indexer] Rescan failed: ${err.message}`);
|
||||
});
|
||||
sendJson(res, 202, { message: 'Rescan started' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 404
|
||||
sendError(res, 404, `Unknown endpoint: ${path}`);
|
||||
});
|
||||
|
||||
server.on('error', (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
console.error(`\n \x1b[31mError:\x1b[0m Index port ${port} is already in use.`);
|
||||
console.error(` Try: originmain dev --target ... --index-port ${port + 1}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
|
||||
server.listen(port, '127.0.0.1', () => {
|
||||
console.log(` \x1b[2mAST indexer API .............. http://localhost:${port}\x1b[0m`);
|
||||
});
|
||||
|
||||
return {
|
||||
close() {
|
||||
sseClients.forEach((res) => { try { res.end(); } catch { /* ignore */ } });
|
||||
sseClients = [];
|
||||
server.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
// -- CLI AST Indexer ------------------------------------------------------
|
||||
// Walks a React TypeScript project and builds a component index using
|
||||
// TypeScript's compiler API in parse-only mode (no type-checking, no
|
||||
// tsconfig program). Fast enough for incremental watch updates.
|
||||
//
|
||||
// What is indexed per file:
|
||||
// - Named/default exported functions whose bodies contain at least one JSX node
|
||||
// - Their first parameter type annotation (Props type, opaque - not expanded)
|
||||
// - var(--token) references in JSX attributes and template literals
|
||||
// - CSS/SCSS/module imports (for CSS file change -> re-index logic)
|
||||
//
|
||||
// What is NOT indexed:
|
||||
// - node_modules, files > 500KB
|
||||
// - Full type resolution (requires full ts.createProgram, deferred post-Phase 7)
|
||||
|
||||
import { readFileSync, statSync } from 'node:fs';
|
||||
import { resolve, relative, extname, join } from 'node:path';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import * as ts from 'typescript';
|
||||
import chokidar from 'chokidar';
|
||||
import { glob } from 'tinyglobby';
|
||||
|
||||
export interface PropEntry {
|
||||
name: string;
|
||||
type: string; // "string | undefined" -- parse-only, may be opaque type name
|
||||
optional: boolean;
|
||||
}
|
||||
|
||||
export interface ComponentEntry {
|
||||
name: string;
|
||||
definitionFile: string; // absolute path
|
||||
relativeFile: string; // relative to project root: "src/components/Card.tsx"
|
||||
lineNumber: number; // 1-indexed line of the export declaration
|
||||
isDefaultExport: boolean;
|
||||
props: PropEntry[];
|
||||
tokensUsed: string[]; // CSS custom properties: ["--color-primary"]
|
||||
cssImports: string[]; // relative paths of CSS/SCSS/module imports
|
||||
lastIndexed: number; // Date.now()
|
||||
}
|
||||
|
||||
export type IndexerStatus = 'idle' | 'indexing' | 'ready' | 'error';
|
||||
|
||||
// -- CSS token regex --------------------------------------------------------
|
||||
// Matches var(--any-valid-custom-property-name)
|
||||
const CSS_TOKEN_RE = /var\((--[-\w]+)\)/g;
|
||||
|
||||
// -- CSS import extensions --------------------------------------------------
|
||||
const CSS_EXTS = new Set(['.css', '.scss', '.sass', '.less', '.module.css', '.module.scss']);
|
||||
function isCssImport(path: string): boolean {
|
||||
const ext = path.slice(path.lastIndexOf('.'));
|
||||
return CSS_EXTS.has(ext) || path.includes('.module.');
|
||||
}
|
||||
|
||||
// -- JSX detection ----------------------------------------------------------
|
||||
// Returns true if the given AST node (or any descendant) is a JSX element/fragment.
|
||||
function containsJsx(node: ts.Node): boolean {
|
||||
if (
|
||||
node.kind === ts.SyntaxKind.JsxElement ||
|
||||
node.kind === ts.SyntaxKind.JsxSelfClosingElement ||
|
||||
node.kind === ts.SyntaxKind.JsxFragment
|
||||
) return true;
|
||||
let found = false;
|
||||
ts.forEachChild(node, (child) => {
|
||||
if (found) return;
|
||||
if (containsJsx(child)) found = true;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
// -- CSS token extraction ---------------------------------------------------
|
||||
// Scans the full source text for var(--token) references.
|
||||
function extractTokens(sourceText: string): string[] {
|
||||
const tokens = new Set<string>();
|
||||
let m: RegExpExecArray | null;
|
||||
CSS_TOKEN_RE.lastIndex = 0;
|
||||
while ((m = CSS_TOKEN_RE.exec(sourceText)) !== null) {
|
||||
tokens.add(m[1] as string);
|
||||
}
|
||||
return [...tokens];
|
||||
}
|
||||
|
||||
// -- Prop extraction (parse-only) ------------------------------------------
|
||||
// Extracts prop names + types from the first parameter type annotation.
|
||||
// Handles inline object types: ({ color, size }: { color: string; size: number })
|
||||
// For opaque types (imported or aliased), returns a single entry with the type name.
|
||||
function extractProps(param: ts.ParameterDeclaration, sourceFile: ts.SourceFile): PropEntry[] {
|
||||
if (!param.type) return [];
|
||||
const typeNode = param.type;
|
||||
|
||||
// Inline type literal: { color: string; size?: number }
|
||||
if (ts.isTypeLiteralNode(typeNode)) {
|
||||
return typeNode.members
|
||||
.filter(ts.isPropertySignature)
|
||||
.map((m) => ({
|
||||
name: m.name.getText(sourceFile),
|
||||
type: m.type ? m.type.getText(sourceFile) : 'unknown',
|
||||
optional: !!m.questionToken,
|
||||
}));
|
||||
}
|
||||
|
||||
// Opaque type reference or destructured pattern: return a single entry
|
||||
const typeName = typeNode.getText(sourceFile);
|
||||
const paramName = param.name.getText(sourceFile);
|
||||
return [{
|
||||
name: paramName.startsWith('{') ? 'props' : paramName,
|
||||
type: typeName,
|
||||
optional: !!param.questionToken,
|
||||
}];
|
||||
}
|
||||
|
||||
// -- Get line number for a node --------------------------------------------
|
||||
function getLine(node: ts.Node, sourceFile: ts.SourceFile): number {
|
||||
const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
|
||||
return line + 1; // 0-indexed to 1-indexed
|
||||
}
|
||||
|
||||
// -- Parse a single source file -> ComponentEntry[] -----------------------
|
||||
function parseFile(filePath: string, projectRoot: string): ComponentEntry[] {
|
||||
// Skip files > 500KB
|
||||
try {
|
||||
const stat = statSync(filePath);
|
||||
if (stat.size > 500 * 1024) {
|
||||
console.warn(`[originmain indexer] Skipping large file (${Math.round(stat.size / 1024)}KB): ${filePath}`);
|
||||
return [];
|
||||
}
|
||||
} catch { return []; }
|
||||
|
||||
let source: string;
|
||||
try { source = readFileSync(filePath, 'utf-8'); }
|
||||
catch { return []; }
|
||||
|
||||
const ext = extname(filePath);
|
||||
const scriptKind =
|
||||
ext === '.tsx' ? ts.ScriptKind.TSX :
|
||||
ext === '.ts' ? ts.ScriptKind.TS :
|
||||
ext === '.jsx' ? ts.ScriptKind.JSX :
|
||||
ts.ScriptKind.JS;
|
||||
|
||||
const sourceFile = ts.createSourceFile(
|
||||
filePath,
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
/* setParentNodes */ true,
|
||||
scriptKind,
|
||||
);
|
||||
|
||||
const relativeFile = relative(projectRoot, filePath).replace(/\\/g, '/');
|
||||
const tokensUsed = extractTokens(source);
|
||||
const now = Date.now();
|
||||
|
||||
// Collect CSS imports
|
||||
const cssImports: string[] = [];
|
||||
sourceFile.statements.forEach((stmt) => {
|
||||
if (ts.isImportDeclaration(stmt) && ts.isStringLiteral(stmt.moduleSpecifier)) {
|
||||
const importPath = stmt.moduleSpecifier.text;
|
||||
if (isCssImport(importPath)) cssImports.push(importPath);
|
||||
}
|
||||
});
|
||||
|
||||
const entries: ComponentEntry[] = [];
|
||||
|
||||
function tryExtract(
|
||||
name: string,
|
||||
params: ts.NodeArray<ts.ParameterDeclaration>,
|
||||
body: ts.Block | ts.Expression | undefined,
|
||||
isDefault: boolean,
|
||||
declarationNode: ts.Node,
|
||||
): void {
|
||||
if (!name || !/^[A-Z]/.test(name)) return;
|
||||
if (!body) return;
|
||||
if (!containsJsx(body)) return;
|
||||
|
||||
const props = params.length > 0 && params[0] ? extractProps(params[0], sourceFile) : [];
|
||||
entries.push({
|
||||
name,
|
||||
definitionFile: filePath,
|
||||
relativeFile,
|
||||
lineNumber: getLine(declarationNode, sourceFile),
|
||||
isDefaultExport: isDefault,
|
||||
props,
|
||||
tokensUsed,
|
||||
cssImports,
|
||||
lastIndexed: now,
|
||||
});
|
||||
}
|
||||
|
||||
sourceFile.statements.forEach((stmt) => {
|
||||
// export function MyComponent(...) { ... }
|
||||
if (
|
||||
ts.isFunctionDeclaration(stmt) &&
|
||||
stmt.name &&
|
||||
stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) &&
|
||||
!stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.DefaultKeyword)
|
||||
) {
|
||||
tryExtract(stmt.name.text, stmt.parameters, stmt.body, false, stmt);
|
||||
}
|
||||
|
||||
// export default function MyComponent(...) { ... }
|
||||
if (
|
||||
ts.isFunctionDeclaration(stmt) &&
|
||||
stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.DefaultKeyword) &&
|
||||
stmt.body
|
||||
) {
|
||||
const name = stmt.name?.text ?? 'Default';
|
||||
tryExtract(name, stmt.parameters, stmt.body, true, stmt);
|
||||
}
|
||||
|
||||
// export const MyComponent = (...) => ...
|
||||
// export const MyComponent = function(...) { ... }
|
||||
if (
|
||||
ts.isVariableStatement(stmt) &&
|
||||
stmt.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword)
|
||||
) {
|
||||
stmt.declarationList.declarations.forEach((decl) => {
|
||||
if (!ts.isIdentifier(decl.name) || !decl.initializer) return;
|
||||
const name = decl.name.text;
|
||||
if (ts.isArrowFunction(decl.initializer) || ts.isFunctionExpression(decl.initializer)) {
|
||||
tryExtract(name, decl.initializer.parameters, decl.initializer.body, false, stmt);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// export default <arrow or function expression>
|
||||
if (ts.isExportAssignment(stmt) && !stmt.isExportEquals) {
|
||||
const expr = stmt.expression;
|
||||
if (ts.isArrowFunction(expr) || ts.isFunctionExpression(expr)) {
|
||||
const name = (ts.isFunctionExpression(expr) && expr.name) ? expr.name.text : 'Default';
|
||||
tryExtract(name, expr.parameters, expr.body, true, stmt);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
// -- Indexer class ----------------------------------------------------------
|
||||
|
||||
export class Indexer extends EventEmitter {
|
||||
private projectRoot: string;
|
||||
private components = new Map<string, ComponentEntry[]>(); // filePath -> entries
|
||||
private cssToComponents = new Map<string, Set<string>>(); // cssPath -> set of component filePaths
|
||||
private watcher: ReturnType<typeof chokidar.watch> | null = null;
|
||||
status: IndexerStatus = 'idle';
|
||||
lastScan = 0;
|
||||
|
||||
constructor(projectRoot: string) {
|
||||
super();
|
||||
this.projectRoot = projectRoot;
|
||||
}
|
||||
|
||||
/** Full scan of all .ts/.tsx/.js/.jsx files in the project root (excluding node_modules). */
|
||||
async fullScan(): Promise<void> {
|
||||
this.status = 'indexing';
|
||||
this.emit('status', this.status);
|
||||
this.components.clear();
|
||||
this.cssToComponents.clear();
|
||||
|
||||
const files = await this._collectFiles();
|
||||
for (const f of files) {
|
||||
this._indexFile(f);
|
||||
}
|
||||
|
||||
this.status = 'ready';
|
||||
this.lastScan = Date.now();
|
||||
this.emit('status', this.status);
|
||||
this.emit('ready', { indexed: this.componentCount() });
|
||||
console.log(`[originmain indexer] Indexed ${this.componentCount()} components from ${files.length} files`);
|
||||
}
|
||||
|
||||
/** Incrementally re-index a single file (called on file change events). */
|
||||
reindexFile(filePath: string): void {
|
||||
this._indexFile(filePath);
|
||||
this.emit('update', { file: relative(this.projectRoot, filePath).replace(/\\/g, '/') });
|
||||
}
|
||||
|
||||
/** Start the file watcher. Calls fullScan() first. */
|
||||
async watch(): Promise<void> {
|
||||
await this.fullScan();
|
||||
|
||||
this.watcher = chokidar.watch(this.projectRoot, {
|
||||
ignored: /(node_modules|\.git)/,
|
||||
persistent: true,
|
||||
ignoreInitial: true,
|
||||
});
|
||||
|
||||
this.watcher.on('change', (filePath) => {
|
||||
const ext = extname(filePath);
|
||||
if (['.ts', '.tsx', '.js', '.jsx'].includes(ext)) {
|
||||
console.log(`[originmain indexer] Re-indexing ${relative(this.projectRoot, filePath)}`);
|
||||
this.reindexFile(filePath);
|
||||
} else if (CSS_EXTS.has(ext) || filePath.includes('.module.')) {
|
||||
this._onCssFileChange(filePath);
|
||||
}
|
||||
});
|
||||
|
||||
this.watcher.on('add', (filePath) => {
|
||||
const ext = extname(filePath);
|
||||
if (['.ts', '.tsx', '.js', '.jsx'].includes(ext)) {
|
||||
this.reindexFile(filePath);
|
||||
}
|
||||
});
|
||||
|
||||
this.watcher.on('unlink', (filePath) => {
|
||||
this.components.delete(filePath);
|
||||
this.emit('update', { file: relative(this.projectRoot, filePath).replace(/\\/g, '/') });
|
||||
});
|
||||
}
|
||||
|
||||
/** Stop the watcher. */
|
||||
async stop(): Promise<void> {
|
||||
await this.watcher?.close();
|
||||
}
|
||||
|
||||
/** All component entries, flattened. */
|
||||
allComponents(): ComponentEntry[] {
|
||||
return [...this.components.values()].flat();
|
||||
}
|
||||
|
||||
componentCount(): number {
|
||||
return this.allComponents().length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query by name (substring / case-insensitive fuzzy) or by file path.
|
||||
* Returns up to 20 results.
|
||||
*/
|
||||
query(opts: { name?: string; file?: string }): ComponentEntry[] {
|
||||
const all = this.allComponents();
|
||||
if (opts.file) return all.filter((c) => c.relativeFile.includes(opts.file!));
|
||||
if (opts.name) {
|
||||
const lower = opts.name.toLowerCase();
|
||||
return all.filter((c) => c.name.toLowerCase().includes(lower)).slice(0, 20);
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
// -- Private helpers -------------------------------------------------------
|
||||
|
||||
private _indexFile(filePath: string): void {
|
||||
try {
|
||||
const entries = parseFile(filePath, this.projectRoot);
|
||||
this.components.set(filePath, entries);
|
||||
|
||||
// Build reverse CSS -> component map for incremental CSS updates
|
||||
for (const entry of entries) {
|
||||
for (const cssRel of entry.cssImports) {
|
||||
const cssAbs = resolve(filePath, '..', cssRel);
|
||||
let set = this.cssToComponents.get(cssAbs);
|
||||
if (!set) { set = new Set(); this.cssToComponents.set(cssAbs, set); }
|
||||
set.add(filePath);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[originmain indexer] Parse error in ${filePath}: ${String(err)}`);
|
||||
this.components.set(filePath, []);
|
||||
}
|
||||
}
|
||||
|
||||
private _onCssFileChange(cssPath: string): void {
|
||||
const affected = this.cssToComponents.get(cssPath);
|
||||
if (!affected) return;
|
||||
for (const componentFile of affected) {
|
||||
this.reindexFile(componentFile);
|
||||
}
|
||||
const rel = relative(this.projectRoot, cssPath).replace(/\\/g, '/');
|
||||
this.emit('update', { file: rel, reason: 'css-tokens' });
|
||||
}
|
||||
|
||||
private async _collectFiles(): Promise<string[]> {
|
||||
// tinyglobby works on Node 18+, unlike node:fs/promises glob (Node 22+).
|
||||
// This was the root cause of M-6/M-7: Node 20 LTS users got a silent
|
||||
// empty index because the dynamic import of node:fs/promises glob threw.
|
||||
try {
|
||||
const matches = await glob('**/*.{ts,tsx,js,jsx}', {
|
||||
cwd: this.projectRoot,
|
||||
ignore: [
|
||||
'**/node_modules/**',
|
||||
'**/.git/**',
|
||||
'**/dist/**',
|
||||
'**/.next/**',
|
||||
],
|
||||
onlyFiles: true,
|
||||
absolute: false,
|
||||
});
|
||||
return matches.map((f: string) => join(this.projectRoot, f));
|
||||
} catch (err) {
|
||||
console.warn(`[originmain indexer] File collection failed: ${String(err)}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
-31
@@ -1,28 +1,40 @@
|
||||
// ── HTML Injection ───────────────────────────────────────────────────────────
|
||||
// -- 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.
|
||||
//
|
||||
// Also injects window.__OM_INDEX_URL__ (AST indexer API) and
|
||||
// window.__OM_ISO_BASE__ (isolation artboard base path) so the canvas can
|
||||
// discover the indexer without any out-of-band coordination.
|
||||
|
||||
import { buildProxyFiberHookScript } from '@originmain/renderer';
|
||||
|
||||
/** The fiber hook script wrapped in a <script> tag, generated once at startup. */
|
||||
let cachedScriptTag: string | undefined;
|
||||
let cachedFiberTag: string | undefined;
|
||||
|
||||
function getScriptTag(): string {
|
||||
if (cachedScriptTag === undefined) {
|
||||
cachedScriptTag = `<script data-originmain-fiber-hook>${buildProxyFiberHookScript()}</script>`;
|
||||
function getFiberTag(): string {
|
||||
if (cachedFiberTag === undefined) {
|
||||
cachedFiberTag = `<script data-originmain-fiber-hook>${buildProxyFiberHookScript()}</script>`;
|
||||
}
|
||||
return cachedScriptTag;
|
||||
return cachedFiberTag;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the bridge config <script> tag.
|
||||
* Not cached because indexUrl may vary per process invocation.
|
||||
*/
|
||||
function getBridgeConfigTag(indexUrl: string | null | undefined): string {
|
||||
const indexUrlJson = indexUrl ? JSON.stringify(indexUrl) : 'null';
|
||||
return (
|
||||
`<script data-originmain-bridge-config>` +
|
||||
`window.__OM_INDEX_URL__=${indexUrlJson};` +
|
||||
`window.__OM_ISO_BASE__="/__om_isolation__";` +
|
||||
`</script>`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
@@ -32,34 +44,26 @@ function stripMetaCsp(html: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject the fiber hook script into an HTML string.
|
||||
*
|
||||
* 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.
|
||||
* Inject the fiber hook + bridge config scripts into an HTML string.
|
||||
*/
|
||||
export function injectFiberHook(html: string): string {
|
||||
const tag = getScriptTag();
|
||||
const cleaned = stripMetaCsp(html);
|
||||
export function injectFiberHook(html: string, indexUrl?: string | null): string {
|
||||
const injection = getBridgeConfigTag(indexUrl) + getFiberTag();
|
||||
const cleaned = stripMetaCsp(html);
|
||||
|
||||
// Try after <head>
|
||||
const headMatch = /<head[^>]*>/i.exec(cleaned);
|
||||
if (headMatch) {
|
||||
const headMatch = cleaned.match(/<head[^>]*>/i);
|
||||
if (headMatch?.index !== undefined) {
|
||||
const insertAt = headMatch.index + headMatch[0].length;
|
||||
return cleaned.slice(0, insertAt) + tag + cleaned.slice(insertAt);
|
||||
return cleaned.slice(0, insertAt) + injection + cleaned.slice(insertAt);
|
||||
}
|
||||
|
||||
// Try after <html>
|
||||
const htmlMatch = /<html[^>]*>/i.exec(cleaned);
|
||||
if (htmlMatch) {
|
||||
const htmlMatch = cleaned.match(/<html[^>]*>/i);
|
||||
if (htmlMatch?.index !== undefined) {
|
||||
const insertAt = htmlMatch.index + htmlMatch[0].length;
|
||||
return cleaned.slice(0, insertAt) + tag + cleaned.slice(insertAt);
|
||||
return cleaned.slice(0, insertAt) + injection + cleaned.slice(insertAt);
|
||||
}
|
||||
|
||||
// Final fallback: prepend
|
||||
return tag + cleaned;
|
||||
return injection + cleaned;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// ── Isolation Server (Phase 3 stub) ──────────────────────────────────────────
|
||||
// Serves `/__om_isolation__` wrapper pages that render a single component in
|
||||
// isolation (component artboard type). Full implementation ships in Phase 3.
|
||||
//
|
||||
// Current status: 501 stub that tells the user Phase 3 is required.
|
||||
// The proxy delegates all /__om_isolation__ requests here.
|
||||
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
|
||||
const STUB_BODY = [
|
||||
'<!DOCTYPE html>',
|
||||
'<html lang="en">',
|
||||
'<head><meta charset="UTF-8" /><title>Isolation artboard — not yet available</title>',
|
||||
'<style>body{margin:0;background:#0d0d11;color:rgba(255,255,255,0.6);',
|
||||
'font:13px/1.6 ui-monospace,monospace;display:flex;align-items:center;',
|
||||
'justify-content:center;height:100vh;text-align:center;}</style></head>',
|
||||
'<body>',
|
||||
'<div>',
|
||||
' <p style="font-size:1.1rem;color:rgba(255,255,255,0.85)">',
|
||||
' Isolation artboards require the CLI AST indexer',
|
||||
' </p>',
|
||||
' <p>Start <code style="color:#7EB8FF">originmain dev</code> without ',
|
||||
' <code style="color:#7EB8FF">--no-index</code> to enable isolation frames.</p>',
|
||||
'</div>',
|
||||
'</body></html>',
|
||||
].join('\n');
|
||||
|
||||
/** Handles any request to /__om_isolation__/* — returns a 501 stub page. */
|
||||
export function handleIsolationRequest(
|
||||
_req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
): void {
|
||||
res.writeHead(501, {
|
||||
'content-type': 'text/html; charset=utf-8',
|
||||
'cache-control': 'no-store',
|
||||
});
|
||||
res.end(STUB_BODY);
|
||||
}
|
||||
@@ -15,12 +15,15 @@ import type { IncomingMessage, ServerResponse,
|
||||
RequestOptions, ClientRequest } from 'node:http';
|
||||
import type { Socket } from 'node:net';
|
||||
import { injectFiberHook } from './inject.js';
|
||||
import { handleIsolationRequest } from './isolation-server.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;
|
||||
/** URL of the AST indexer API (null when --no-index). Injected as window.__OM_INDEX_URL__ */
|
||||
indexUrl?: string | null;
|
||||
}
|
||||
|
||||
/** Headers to strip from proxied responses (case-insensitive). */
|
||||
@@ -66,6 +69,12 @@ export function startProxy(opts: ProxyOptions): { close: () => void } {
|
||||
const targetPort = parseInt(targetUrl.port || (isHttps ? '443' : '80'), 10);
|
||||
|
||||
const server = createServer((clientReq: IncomingMessage, clientRes: ServerResponse) => {
|
||||
// ── Intercept /__om_isolation__/* requests ────────────────────────────
|
||||
if (clientReq.url?.startsWith('/__om_isolation__')) {
|
||||
handleIsolationRequest(clientReq, clientRes);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Build the outgoing request headers ────────────────────────────────
|
||||
|
||||
const outHeaders: Record<string, string | string[]> = {};
|
||||
@@ -160,7 +169,7 @@ export function startProxy(opts: ProxyOptions): { close: () => void } {
|
||||
|
||||
proxyRes.on('end', () => {
|
||||
const rawHtml = Buffer.concat(chunks).toString('utf-8');
|
||||
const injectedHtml = injectFiberHook(rawHtml);
|
||||
const injectedHtml = injectFiberHook(rawHtml, opts.indexUrl);
|
||||
const body = Buffer.from(injectedHtml, 'utf-8');
|
||||
|
||||
// Correct Content-Length and drop Transfer-Encoding: chunked.
|
||||
@@ -249,13 +258,18 @@ export function startProxy(opts: ProxyOptions): { close: () => void } {
|
||||
|
||||
// ── Start listening ───────────────────────────────────────────────────────
|
||||
|
||||
server.listen(opts.port, () => {
|
||||
// H-5 fix: bind to loopback only — the proxy strips security headers and
|
||||
// injects the fiber hook, so it must never be reachable from the LAN.
|
||||
server.listen(opts.port, '127.0.0.1', () => {
|
||||
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`);
|
||||
if (opts.indexUrl) {
|
||||
console.log(` Indexer: \x1b[1m${opts.indexUrl}\x1b[0m`);
|
||||
}
|
||||
console.log('');
|
||||
console.log(' Paste the proxy URL into your Originmain artboard\'s');
|
||||
console.log(' "Connect app" field to enable live rendering.');
|
||||
@@ -263,6 +277,10 @@ 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');
|
||||
console.log(opts.indexUrl
|
||||
? ' \x1b[2mAST indexer ................. active\x1b[0m'
|
||||
: ' \x1b[2mAST indexer ................. disabled (--no-index)\x1b[0m',
|
||||
);
|
||||
if (isHttps) {
|
||||
console.log(' \x1b[2mHTTPS → HTTP bridge ......... active\x1b[0m');
|
||||
}
|
||||
|
||||
Vendored
+29
@@ -0,0 +1,29 @@
|
||||
// Ambient declaration for tinyglobby (0.2.16 ships its types as
|
||||
// `./dist/index.d.cts` per package.json but that file isn't actually in the
|
||||
// tarball). Cover only the surface we use; widen if more API surface lands.
|
||||
|
||||
declare module 'tinyglobby' {
|
||||
export interface GlobOptions {
|
||||
cwd?: string;
|
||||
ignore?: string | string[];
|
||||
onlyFiles?: boolean;
|
||||
onlyDirectories?: boolean;
|
||||
absolute?: boolean;
|
||||
dot?: boolean;
|
||||
expandDirectories?: boolean;
|
||||
followSymbolicLinks?: boolean;
|
||||
caseSensitiveMatch?: boolean;
|
||||
deep?: number;
|
||||
patterns?: string | string[];
|
||||
}
|
||||
|
||||
export function glob(
|
||||
patterns: string | string[],
|
||||
options?: GlobOptions,
|
||||
): Promise<string[]>;
|
||||
|
||||
export function globSync(
|
||||
patterns: string | string[],
|
||||
options?: GlobOptions,
|
||||
): string[];
|
||||
}
|
||||
Reference in New Issue
Block a user