made tiny updates

This commit is contained in:
SinachPat
2026-05-03 20:34:18 +01:00
parent 4dca0f1bac
commit 3f029e15c2
30 changed files with 3463 additions and 357 deletions
+5 -2
View File
@@ -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
View File
@@ -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);
});
+123
View File
@@ -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 };
}
+246
View File
@@ -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();
},
};
}
+391
View File
@@ -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
View File
@@ -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;
}
+38
View File
@@ -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);
}
+20 -2
View File
@@ -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');
}
+29
View File
@@ -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[];
}