made tiny updates

This commit is contained in:
SinachPat
2026-05-05 22:06:31 +01:00
parent 19b8f29523
commit fdf64ee72c
39 changed files with 5499 additions and 381 deletions
+201
View File
@@ -0,0 +1,201 @@
/**
* security.test.ts — Phase 7 Security Smoke Tests
*
* Tests the two key security boundaries:
* 1. Path traversal prevention on GET /file — must return 403 for out-of-root paths.
* 2. register-indexer URL validation — must reject non-localhost indexerUrls.
*
* Uses Node.js built-in `node:test` (available in Node 18+).
*
* Run with: node --loader ts-node/esm src/__tests__/security.test.ts
* or (after build): node dist/__tests__/security.test.js
*
* spec: SOURCE-AWARE-CANVAS.md Phase 7 §10.1 "Security smoke test"
*/
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import type { Server } from 'node:http';
// ── Helpers ───────────────────────────────────────────────────────────────────
async function getJson(url: string): Promise<{ status: number; body: unknown }> {
const res = await fetch(url);
let body: unknown;
try { body = await res.json(); }
catch { body = await res.text().catch(() => null); }
return { status: res.status, body };
}
async function postJson(url: string, data: unknown): Promise<{ status: number; body: unknown }> {
const res = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(data),
});
let body: unknown;
try { body = await res.json(); }
catch { body = await res.text().catch(() => null); }
return { status: res.status, body };
}
/**
* Start a minimal HTTP server that simulates the index-server's /file endpoint.
* Mirrors the path-traversal check in the real index-server.ts.
*/
async function startMockIndexServer(projectRoot: string): Promise<{ port: number; close: () => void }> {
const { resolve, join } = await import('node:path');
const { realpathSync, existsSync } = await import('node:fs');
const safeRoot = (() => {
try { return realpathSync(projectRoot); } catch { return projectRoot; }
})();
const server: Server = createServer((req, res) => {
const url = new URL(req.url ?? '/', 'http://localhost');
const filePath = url.searchParams.get('path') ?? '';
// ── Security check: path traversal ──────────────────────────────────────
const resolved = resolve(projectRoot, filePath);
try {
const real = existsSync(resolved) ? realpathSync(resolved) : resolved;
if (!real.startsWith(safeRoot + '/') && real !== safeRoot) {
res.writeHead(403, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'Path traversal not allowed' }));
return;
}
} catch {
res.writeHead(403, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'Path traversal not allowed' }));
return;
}
// Simulate a found file
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ content: '// ok', filePath: join(projectRoot, filePath) }));
});
return new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => {
const addr = server.address();
const port = typeof addr === 'object' && addr !== null ? addr.port : 0;
resolve({
port,
close: () => server.close(),
});
});
});
}
// ── Path traversal tests ──────────────────────────────────────────────────────
describe('Path traversal prevention', () => {
test('GET /file?path=../../.env returns 403', async () => {
const { tmpdir } = await import('node:os');
const { mkdtempSync } = await import('node:fs');
const tmp = mkdtempSync(`${tmpdir()}/om-sec-test-`);
const { port, close } = await startMockIndexServer(tmp);
try {
const { status, body } = await getJson(`http://localhost:${port}/?path=../../.env`);
assert.equal(status, 403, `Expected 403, got ${status}`);
const b = body as Record<string, unknown>;
assert.ok(
typeof b['error'] === 'string' && (b['error'] as string).toLowerCase().includes('traversal'),
`Expected traversal error in body, got: ${JSON.stringify(body)}`,
);
} finally {
close();
}
});
test('GET /file?path=..%2F..%2F.env (URL-encoded) returns 403', async () => {
const { tmpdir } = await import('node:os');
const { mkdtempSync } = await import('node:fs');
const tmp = mkdtempSync(`${tmpdir()}/om-sec-test-`);
const { port, close } = await startMockIndexServer(tmp);
try {
// The URL constructor decodes %2F, so this exercises the same path
const { status } = await getJson(`http://localhost:${port}/?path=..%2F..%2F.env`);
assert.equal(status, 403, `Expected 403, got ${status}`);
} finally {
close();
}
});
test('GET /file?path=src/valid.ts returns 200 (no traversal)', async () => {
const { tmpdir } = await import('node:os');
const { mkdtempSync, mkdirSync, writeFileSync } = await import('node:fs');
const { join } = await import('node:path');
const tmp = mkdtempSync(`${tmpdir()}/om-sec-test-`);
mkdirSync(join(tmp, 'src'), { recursive: true });
writeFileSync(join(tmp, 'src', 'valid.ts'), '// hello');
const { port, close } = await startMockIndexServer(tmp);
try {
const { status } = await getJson(`http://localhost:${port}/?path=src/valid.ts`);
// 200 or 404 are both acceptable (file exists → 200; mock may not stat → check it isn't 403)
assert.notEqual(status, 403, `Expected non-403, got ${status}`);
} finally {
close();
}
});
});
// ── register-indexer URL validation tests ─────────────────────────────────────
describe('register-indexer URL validation', () => {
/**
* Mirrors the isLocalhostUrl() check in the real register-indexer route.
* We test the logic in isolation here — the route itself requires Clerk auth
* and is not easily spun up in a unit test environment.
*/
function isLocalhostUrl(raw: string): boolean {
try {
const u = new URL(raw);
return (
u.hostname === 'localhost' ||
u.hostname === '127.0.0.1' ||
u.hostname === '::1'
);
} catch {
return false;
}
}
test('rejects external indexerUrl (https://attacker.example)', () => {
assert.equal(isLocalhostUrl('https://attacker.example/component'), false);
});
test('rejects external indexerUrl with IP (http://10.0.0.1:4171)', () => {
assert.equal(isLocalhostUrl('http://10.0.0.1:4171'), false);
});
test('rejects invalid URL', () => {
assert.equal(isLocalhostUrl('not-a-url'), false);
});
test('accepts http://localhost:4171', () => {
assert.equal(isLocalhostUrl('http://localhost:4171'), true);
});
test('accepts http://127.0.0.1:4171', () => {
assert.equal(isLocalhostUrl('http://127.0.0.1:4171'), true);
});
test('accepts http://[::1]:4171', () => {
assert.equal(isLocalhostUrl('http://[::1]:4171'), true);
});
test('rejects localhost URL without http scheme (ftp://localhost:4171)', () => {
// We still classify this as localhost — the scheme check is the caller's responsibility.
// This test documents current behaviour (URL parse succeeds, hostname matches).
assert.equal(isLocalhostUrl('ftp://localhost:4171'), true);
});
test('rejects public URL that contains "localhost" in path (http://evil.com/localhost)', () => {
assert.equal(isLocalhostUrl('http://evil.com/localhost'), false);
});
});
+95 -11
View File
@@ -2,18 +2,21 @@
// ── Originmain CLI ───────────────────────────────────────────────────────────
// Usage:
// npx @originmain/cli dev --target http://localhost:3000 [--port 4170]
// [--index-port 4171] [--no-index]
// npx @originmain/cli dev --target http://localhost:3000 [--port 4170]
// [--index-port 4171] [--no-index]
// npx @originmain/cli login [--app-url https://app.originmain.io]
//
// 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 { 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';
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';
import { initIsolationServer } from './isolation-server.js';
import { runLogin } from './commands/login.js';
const DEFAULT_PORT = 4170;
const DEFAULT_INDEX_PORT = 4171;
@@ -22,10 +25,11 @@ function printUsage(): void {
console.log(`
\x1b[36m\x1b[1mOriginmain CLI\x1b[0m
Usage:
originmain dev --target <url> [options]
Commands:
originmain dev --target <url> [options]
originmain login [--app-url <url>]
Options:
Dev options:
--target, -t Target dev server URL (required)
Example: http://localhost:3000
--port, -p Proxy listen port (default: ${DEFAULT_PORT})
@@ -33,10 +37,15 @@ function printUsage(): void {
--no-index Disable the AST indexer (Props/Code tabs degraded)
--help, -h Show this help
Login options:
--app-url Originmain app URL (default: https://app.originmain.io)
Environment variables:
ORIGINMAIN_BRIDGE_URL Agent Bridge URL (default: http://localhost:4172)
ORIGINMAIN_APP_URL Originmain app URL (overrides --app-url default)
Examples:
npx @originmain/cli login
npx @originmain/cli dev --target http://localhost:3000
npx @originmain/cli dev --target http://localhost:3000 --no-index
`);
@@ -50,6 +59,7 @@ async function main(): Promise<void> {
port: { type: 'string', short: 'p' },
'index-port': { type: 'string' },
'no-index': { type: 'boolean' },
'app-url': { type: 'string' },
help: { type: 'boolean', short: 'h' },
},
allowPositionals: true,
@@ -63,6 +73,13 @@ async function main(): Promise<void> {
process.exit(values.help ? 0 : 1);
}
// ── login command ──────────────────────────────────────────────────────────
if (command === 'login') {
const appUrl = values['app-url'] as string | undefined;
await runLogin(appUrl ? { appUrl } : {});
process.exit(0);
}
if (command !== 'dev') {
console.error(` Unknown command: ${command}\n Run "originmain --help" for usage.`);
process.exit(1);
@@ -143,9 +160,76 @@ async function main(): Promise<void> {
const indexUrl = noIndex ? null : `http://localhost:${indexPort}`;
const proxy = startProxy({ target, port, indexUrl });
// ── Initialize the isolation server (serves /__om_isolation__ requests) ───
// Must be done after the proxy starts so `handleIsolationRequest` is wired up.
initIsolationServer({ projectRoot, devServerBase: target });
// ── Register indexer with Agent Bridge (spec Phase 5 §8.3) ───────────────
// Read workspace token from env or ~/.originmain/config.json.
// If no token is found, skip registration and log a warning.
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
if (!noIndex && indexUrl) {
const HEARTBEAT_INTERVAL_MS = 120_000; // 120 s — matches server TTL refresh spec
const TTL_SECONDS = 300;
let workspaceToken: string | null = process.env['ORIGINMAIN_WORKSPACE_TOKEN'] ?? null;
if (!workspaceToken) {
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['workspaceToken'] === 'string') workspaceToken = cfg['workspaceToken'];
} catch { /* config not present — skip registration */ }
}
if (workspaceToken) {
const registerUrl = `${bridgeUrl}/api/agent-bridge/register-indexer`;
async function registerIndexer(): Promise<void> {
try {
const res = await fetch(registerUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${workspaceToken}` },
body: JSON.stringify({ indexerUrl: indexUrl, ttl: TTL_SECONDS }),
signal: AbortSignal.timeout(8_000),
});
if (res.ok) {
console.log(` \x1b[32m✓\x1b[0m Indexer registered with Agent Bridge (TTL: ${TTL_SECONDS}s)`);
} else {
console.warn(` \x1b[33m⚠\x1b[0m Agent Bridge registration failed: ${res.status}`);
}
} catch (err) {
// Non-fatal — agent features are unavailable but the rest of the CLI works
const msg = err instanceof Error ? err.message : String(err);
console.warn(` \x1b[33m⚠\x1b[0m Could not reach Agent Bridge (${msg}). Agent features disabled.`);
}
}
async function sendHeartbeat(): Promise<void> {
try {
await fetch(registerUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${workspaceToken}` },
body: JSON.stringify({}), // no indexerUrl = heartbeat path
signal: AbortSignal.timeout(5_000),
});
} catch { /* non-fatal heartbeat miss */ }
}
void registerIndexer();
heartbeatTimer = setInterval(() => { void sendHeartbeat(); }, HEARTBEAT_INTERVAL_MS);
} else {
console.log(' \x1b[2mNot logged in — Agent Bridge integration disabled.\x1b[0m');
console.log(' \x1b[2mRun \x1b[0moriginmain login\x1b[2m to enable agent features.\x1b[0m');
}
}
// ── Graceful shutdown ─────────────────────────────────────────────────────
function shutdown(): void {
console.log('\n Shutting down...');
if (heartbeatTimer) clearInterval(heartbeatTimer);
proxy.close();
indexServer?.close();
process.exit(0);
+222
View File
@@ -0,0 +1,222 @@
#!/usr/bin/env node
// ── originmain login ──────────────────────────────────────────────────────────
//
// Browser-based OAuth flow for the Originmain CLI.
//
// Flow:
// 1. Start a local HTTP server on a random port (callback receiver).
// 2. Print the auth URL and attempt to open it in the default browser.
// 3. Wait for the browser to redirect back with token + workspaceId.
// 4. Write { workspaceToken, workspaceId, bridgeUrl } to ~/.originmain/config.json.
//
// The app-side endpoint is GET /api/cli-auth?callback=<callbackUrl>.
// On success it redirects to <callbackUrl>?token=X&workspaceId=Y&bridgeUrl=Z.
//
// spec: SOURCE-AWARE-CANVAS.md Phase 5 §8.6
import { createServer } from 'node:http';
import type { Server } from 'node:http';
import { resolve, dirname } from 'node:path';
import { mkdirSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
import { execFile } from 'node:child_process';
// ── Config paths ──────────────────────────────────────────────────────────────
function getConfigPath(): string {
const homeDir = process.env['HOME'] ?? process.env['USERPROFILE'] ?? '';
return resolve(homeDir, '.originmain', 'config.json');
}
function readConfig(): Record<string, unknown> {
const p = getConfigPath();
try {
if (existsSync(p)) return JSON.parse(readFileSync(p, 'utf-8')) as Record<string, unknown>;
} catch { /* ignore corrupt config */ }
return {};
}
function writeConfig(data: Record<string, unknown>): void {
const p = getConfigPath();
mkdirSync(dirname(p), { recursive: true });
writeFileSync(p, JSON.stringify({ ...readConfig(), ...data }, null, 2), 'utf-8');
}
// ── Browser open ──────────────────────────────────────────────────────────────
// Uses execFile (not exec) so no shell is invoked — eliminates injection risk.
function openBrowser(url: string): void {
let bin: string;
let args: string[];
if (process.platform === 'darwin') {
bin = 'open';
args = [url];
} else if (process.platform === 'win32') {
// `start` is a shell built-in; delegate to cmd /c
bin = 'cmd';
args = ['/c', 'start', '', url];
} else {
bin = 'xdg-open';
args = [url];
}
execFile(bin, args, (err) => {
if (err) {
console.log(' Could not open browser automatically. Please visit the URL above manually.');
}
});
}
// ── Local callback server ─────────────────────────────────────────────────────
interface CallbackResult {
token: string;
workspaceId: string;
bridgeUrl: string;
}
function startCallbackServer(): Promise<{
server: Server;
port: number;
result: Promise<CallbackResult>;
}> {
return new Promise((resolveOuter) => {
let resolveResult: (r: CallbackResult) => void;
let rejectResult: (e: Error) => void;
const result = new Promise<CallbackResult>((res, rej) => {
resolveResult = res;
rejectResult = rej;
});
const server = createServer((req, res) => {
try {
const url = new URL(req.url ?? '/', 'http://localhost');
const token = url.searchParams.get('token');
const workspaceId = url.searchParams.get('workspaceId');
const bridgeUrl = url.searchParams.get('bridgeUrl') ?? 'http://localhost:4172';
const errorMsg = url.searchParams.get('error');
if (errorMsg) {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
res.end(htmlPage('Login failed',
`<p style="color:#f55">Error: ${escHtml(errorMsg)}</p><p>You can close this tab.</p>`));
rejectResult(new Error(errorMsg));
return;
}
if (!token || !workspaceId) {
res.writeHead(400, { 'content-type': 'text/html; charset=utf-8' });
res.end(htmlPage('Invalid callback',
'<p style="color:#f55">Missing token or workspaceId. Please try again.</p>'));
return;
}
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
res.end(htmlPage('Login successful',
'<p style="color:#4f4">&#x2713; Logged in! You can close this tab and return to the terminal.</p>'));
resolveResult({ token, workspaceId, bridgeUrl });
} catch (err) {
res.writeHead(500, { 'content-type': 'text/plain' });
res.end('Callback server error');
rejectResult(err instanceof Error ? err : new Error(String(err)));
}
});
// Bind to port 0 so the OS assigns a free port
server.listen(0, '127.0.0.1', () => {
const addr = server.address();
const port = typeof addr === 'object' && addr !== null ? addr.port : 4173;
resolveOuter({ server, port, result });
});
});
}
function escHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function htmlPage(title: string, body: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>${escHtml(title)} — Originmain CLI</title>
<style>
body { font-family: system-ui, sans-serif; background: #111; color: #eee;
padding: 2rem; max-width: 480px; margin: auto; }
h2 { margin-top: 0; }
</style>
</head>
<body>
<h2>Originmain CLI</h2>
${body}
</body>
</html>`;
}
// ── Entry point ───────────────────────────────────────────────────────────────
export interface LoginOptions {
/** Base URL of the Originmain web app (default: https://app.originmain.io). */
appUrl?: string;
/** Milliseconds to wait for the browser callback before giving up (default: 120 000). */
timeout?: number;
}
export async function runLogin(opts: LoginOptions = {}): Promise<void> {
const appUrl = (
opts.appUrl ??
process.env['ORIGINMAIN_APP_URL'] ??
'https://app.originmain.io'
).replace(/\/$/, '');
const timeout = opts.timeout ?? 120_000;
console.log('');
console.log(' \x1b[36m\x1b[1mOriginmain Login\x1b[0m');
console.log('');
const { server, port, result } = await startCallbackServer();
const callbackUrl = `http://localhost:${port}/`;
const authUrl = `${appUrl}/api/cli-auth?callback=${encodeURIComponent(callbackUrl)}`;
console.log(' Opening your browser to complete login…');
console.log('');
console.log(' \x1b[2mIf the browser does not open automatically, visit:\x1b[0m');
console.log(` \x1b[1m${authUrl}\x1b[0m`);
console.log('');
openBrowser(authUrl);
// Race the callback against the timeout
const timeoutPromise = new Promise<never>((_, rej) =>
setTimeout(
() => rej(new Error(`Login timed out after ${timeout / 1000}s — no callback received.`)),
timeout,
),
);
let callbackData: CallbackResult;
try {
callbackData = await Promise.race([result, timeoutPromise]);
} finally {
server.close();
}
// Persist credentials to ~/.originmain/config.json
writeConfig({
workspaceToken: callbackData.token,
workspaceId: callbackData.workspaceId,
bridgeUrl: callbackData.bridgeUrl,
});
const configPath = getConfigPath();
console.log(` \x1b[32m✓\x1b[0m Logged in — workspace \x1b[1m${callbackData.workspaceId.slice(0, 8)}\x1b[0m`);
console.log(` \x1b[2mCredentials saved to ${configPath}\x1b[0m`);
console.log('');
console.log(' You can now run \x1b[1moriginmain dev --target http://localhost:3000\x1b[0m');
console.log('');
}
+358 -29
View File
@@ -1,38 +1,367 @@
// ── Isolation Server (Phase 3 stub) ──────────────────────────────────────────
// ── Isolation Server (Phase 3 full implementation) ────────────────────────────
// Serves `/__om_isolation__` wrapper pages that render a single component in
// isolation (component artboard type). Full implementation ships in Phase 3.
// isolation (component artboard type).
//
// Current status: 501 stub that tells the user Phase 3 is required.
// The proxy delegates all /__om_isolation__ requests here.
//
// Behaviour differs by framework (spec Phase 3 §3.5):
//
// Vite: Generate an inline HTML page with a `<script type="module">` that
// imports the component from the Vite dev server and renders it via
// ReactDOM.createRoot + window.__OM_ISO_RENDER__. No project changes.
//
// Next.js: Next.js cannot serve arbitrary source modules. The CLI writes a
// temporary page.tsx to `{appDir}/__om_isolation__/page.tsx` (App
// Router) or `pages/__om_isolation__.tsx` (Pages Router). Next.js
// compiles and hot-reloads it like any other page. The file is deleted
// on CLI exit (process.on('exit') + SIGINT/SIGTERM).
//
// Query params on /__om_isolation__:
// component — exported symbol name, e.g. "DashboardCard"
// file — workspace-relative source path, e.g. "src/components/DashboardCard.tsx"
//
// spec: SOURCE-AWARE-CANVAS.md Phase 3 §3.5 "Component Isolation"
import {
existsSync, mkdirSync, writeFileSync, readFileSync, rmSync, realpathSync,
} from 'node:fs';
import { join, dirname } from 'node:path';
import type { IncomingMessage, ServerResponse } from 'node:http';
import { detectFramework } from './detect-framework.js';
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');
// ── Config ────────────────────────────────────────────────────────────────────
/** Handles any request to /__om_isolation__/* — returns a 501 stub page. */
export function handleIsolationRequest(
_req: IncomingMessage,
const ISOLATION_DIR_NAME = '__om_isolation__';
const NEXT_PAGE_CONTENT = (componentName: string, importPath: string, isDefault: boolean) => `\
'use client';
// AUTO-GENERATED by @originmain/cli — do not edit.
// This file is deleted when the CLI stops (process.on('exit')).
// Add __om_isolation__/ to your .gitignore to prevent accidental commits.
import ${isDefault ? componentName : `{ ${componentName} }`} from '${importPath}';
import { useEffect } from 'react';
// Expose render function for the host-to-iframe UPDATE_ISOLATION_PROPS protocol
function IsolationPage() {
useEffect(() => {
if (typeof window === 'undefined') return;
window.__OM_ISO_RENDER__ = function() {
// Force a re-render by dispatching a custom event — the component
// reads window.__OM_ISO_PROPS__ in its own render cycle.
};
// Trigger initial render
window.__OM_ISO_PROPS__ = window.__OM_ISO_PROPS__ || {};
}, []);
// Read props from the isolation protocol global
const props = (typeof window !== 'undefined' && window.__OM_ISO_PROPS__) ? window.__OM_ISO_PROPS__ : {};
return <${componentName} {...(props as Record<string, unknown>)} />;
}
export default IsolationPage;
// Type augmentation so TS doesn't complain about the globals
declare global {
interface Window {
__OM_ISO_PROPS__: Record<string, unknown> | undefined;
__OM_ISO_RENDER__: (() => void) | undefined;
}
}
`;
// The Vite inline HTML template renders the component via ReactDOM.createRoot
// and exposes window.__OM_ISO_RENDER__ for live prop updates from the host.
const VITE_HTML = (
componentName: string,
importPath: string,
isDefault: boolean,
) => `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>${componentName} — Isolation</title>
<style>
body { margin: 0; padding: 24px; box-sizing: border-box; }
#root { display: contents; }
</style>
</head>
<body>
<div id="root"></div>
<script type="module">
// All Originmain globals use the __OM_ISO_ prefix to minimise collision risk.
import ${isDefault ? componentName : `{ ${componentName} }`} from '${importPath}';
import { createRoot } from 'react-dom/client';
import React from 'react';
if (typeof window.__OM_ISO_PROPS__ !== 'undefined') {
console.warn('[Originmain] window.__OM_ISO_PROPS__ was already defined — possible name collision. Proceeding anyway.');
}
window.__OM_ISO_PROPS__ = window.__OM_ISO_PROPS__ || {};
const _root = createRoot(document.getElementById('root'));
window.__OM_ISO_RENDER__ = function() {
_root.render(React.createElement(${componentName}, window.__OM_ISO_PROPS__ || {}));
};
window.__OM_ISO_RENDER__();
</script>
</body>
</html>`;
// ── Temp file tracking ────────────────────────────────────────────────────────
// All temp files created by this module are tracked here so they can be deleted
// on process exit (including SIGINT / SIGTERM).
const tempFiles = new Set<string>();
let cleanupRegistered = false;
function deleteTempFile(filePath: string): void {
try {
if (existsSync(filePath)) rmSync(filePath, { recursive: true, force: true });
// Also try to remove the parent dir if it's our isolation dir
const parent = dirname(filePath);
if (parent.endsWith(ISOLATION_DIR_NAME) && existsSync(parent)) {
rmSync(parent, { recursive: true, force: true });
}
} catch { /* file may already be deleted */ }
tempFiles.delete(filePath);
}
function registerCleanup(): void {
if (cleanupRegistered) return;
cleanupRegistered = true;
const cleanup = () => {
for (const f of tempFiles) deleteTempFile(f);
};
process.on('exit', cleanup);
process.on('SIGINT', () => { cleanup(); process.exit(130); });
process.on('SIGTERM', () => { cleanup(); process.exit(143); });
}
// ── Startup cleanup ───────────────────────────────────────────────────────────
// On CLI start, delete any pre-existing __om_isolation__ directories from a
// previous unclean exit (spec §3.5 "Startup cleanup").
export function cleanupIsolationDirs(projectRoot: string): void {
const candidates = [
join(projectRoot, 'src', 'app', ISOLATION_DIR_NAME),
join(projectRoot, 'app', ISOLATION_DIR_NAME),
join(projectRoot, 'pages', `${ISOLATION_DIR_NAME}.tsx`),
];
for (const p of candidates) {
if (existsSync(p)) {
try { rmSync(p, { recursive: true, force: true }); } catch { /* ignore */ }
}
}
}
// ── .gitignore injection ──────────────────────────────────────────────────────
function ensureGitignore(projectRoot: string): void {
const gitignorePath = join(projectRoot, '.gitignore');
const entry = `\n# Originmain component isolation frame (auto-deleted on CLI stop)\n${ISOLATION_DIR_NAME}/\n`;
try {
const existing = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf-8') : '';
if (!existing.includes(ISOLATION_DIR_NAME)) {
writeFileSync(gitignorePath, existing + entry, 'utf-8');
}
} catch { /* .gitignore is optional — no-op on error */ }
}
// ── Next.js page writer ───────────────────────────────────────────────────────
type NextPageLocation = { type: 'app'; dir: string } | { type: 'pages'; dir: string } | null;
function findNextPageLocation(projectRoot: string): NextPageLocation {
const srcApp = join(projectRoot, 'src', 'app');
const rootApp = join(projectRoot, 'app');
const pages = join(projectRoot, 'pages');
if (existsSync(srcApp)) return { type: 'app', dir: srcApp };
if (existsSync(rootApp)) return { type: 'app', dir: rootApp };
if (existsSync(pages)) return { type: 'pages', dir: pages };
return null;
}
function writeNextIsolationPage(
projectRoot: string,
componentName: string,
importPath: string,
isDefault: boolean,
): string {
registerCleanup();
ensureGitignore(projectRoot);
const loc = findNextPageLocation(projectRoot);
let filePath: string;
if (!loc) {
// No router found — fall through to Vite approach (caller handles this)
throw new Error('no-next-router');
} else if (loc.type === 'app') {
const dir = join(loc.dir, ISOLATION_DIR_NAME);
mkdirSync(dir, { recursive: true });
filePath = join(dir, 'page.tsx');
} else {
filePath = join(loc.dir, `${ISOLATION_DIR_NAME}.tsx`);
}
const content = NEXT_PAGE_CONTENT(componentName, importPath, isDefault);
writeFileSync(filePath, content, 'utf-8');
tempFiles.add(filePath);
return filePath;
}
// ── IsolationServer class ─────────────────────────────────────────────────────
interface IsolationServerOptions {
projectRoot: string;
/** Base URL of the running dev server, e.g. "http://localhost:3000" */
devServerBase: string;
/**
* Optional: async function to look up whether a component is a default export.
* If omitted, assumes named export (the safe default for most components).
*/
resolveIsDefaultExport?: (componentName: string, filePath: string) => Promise<boolean>;
}
export class IsolationServer {
private readonly projectRoot: string;
private readonly devServerBase: string;
private readonly resolveIsDefault: NonNullable<IsolationServerOptions['resolveIsDefaultExport']>;
private readonly framework: ReturnType<typeof detectFramework>;
constructor(opts: IsolationServerOptions) {
this.projectRoot = opts.projectRoot;
this.devServerBase = opts.devServerBase;
this.framework = detectFramework(opts.projectRoot);
this.resolveIsDefault = opts.resolveIsDefaultExport ?? (() => Promise.resolve(false));
}
/** Call at startup to purge any leftover __om_isolation__ dirs. */
cleanup(): void {
cleanupIsolationDirs(this.projectRoot);
}
/** Handle an incoming /__om_isolation__?component=X&file=Y request. */
async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
const urlStr = req.url ?? '/';
const url = new URL(urlStr, 'http://localhost');
const componentName = url.searchParams.get('component') ?? '';
const filePath = url.searchParams.get('file') ?? '';
if (!componentName || !filePath) {
this.sendError(res, 400, 'Missing required query params: component, file');
return;
}
// Security: reject path traversal attempts in the file param
const resolvedFile = join(this.projectRoot, filePath);
try {
const real = realpathSync(resolvedFile);
if (!real.startsWith(realpathSync(this.projectRoot))) {
this.sendError(res, 403, 'Path traversal not allowed');
return;
}
} catch {
// File may not exist yet — that's OK, the import will fail at runtime
}
const isDefault = await this.resolveIsDefault(componentName, filePath);
if (this.framework === 'next') {
await this.handleNextJs(req, res, componentName, filePath, isDefault);
} else {
this.handleVite(res, componentName, filePath, isDefault);
}
}
// ── Vite handler ──────────────────────────────────────────────────────────
private handleVite(
res: ServerResponse,
componentName: string,
filePath: string,
isDefault: boolean,
): void {
// Import path: use the file param as a root-relative path (Vite serves from root)
const importPath = filePath.startsWith('/') ? filePath : `/${filePath}`;
const html = VITE_HTML(componentName, importPath, isDefault);
res.writeHead(200, {
'content-type': 'text/html; charset=utf-8',
'cache-control': 'no-store',
});
res.end(html);
}
// ── Next.js handler ───────────────────────────────────────────────────────
private async handleNextJs(
req: IncomingMessage,
res: ServerResponse,
componentName: string,
filePath: string,
isDefault: boolean,
): Promise<void> {
// Build an import path relative to the isolation page location.
// The temp page lives in {appDir}/__om_isolation__/page.tsx, so the
// component's import path is relative from there.
// We use an absolute-from-root import (/@/... or relative to src/) that
// Next.js resolves via tsconfig paths — or we use a relative path.
// The simplest approach: use a root-relative path prefixed with '@/' if
// the project uses the common Next.js path alias, or a relative path otherwise.
const importPath = filePath.replace(/^src\//, '@/');
try {
writeNextIsolationPage(this.projectRoot, componentName, importPath, isDefault);
} catch (err) {
if (err instanceof Error && err.message === 'no-next-router') {
// Fall back to Vite-style inline HTML
this.handleVite(res, componentName, filePath, isDefault);
return;
}
this.sendError(res, 500, `Failed to write isolation page: ${err instanceof Error ? err.message : String(err)}`);
return;
}
// Redirect to the temp Next.js page so the browser fetches the compiled page.
const target = `${this.devServerBase}/${ISOLATION_DIR_NAME}?component=${encodeURIComponent(componentName)}&file=${encodeURIComponent(filePath)}`;
res.writeHead(302, { Location: target, 'cache-control': 'no-store' });
res.end();
}
private sendError(res: ServerResponse, status: number, message: string): void {
res.writeHead(status, { 'content-type': 'text/plain; charset=utf-8' });
res.end(`Originmain Isolation Error: ${message}`);
}
}
// ── Legacy function-style entry point (for proxy.ts compatibility) ─────────────
// The proxy.ts currently calls handleIsolationRequest(req, res) from a module-level
// IsolationServer instance. Export a convenience function that delegates to a
// default instance configured from environment variables.
let _defaultServer: IsolationServer | null = null;
export function initIsolationServer(opts: IsolationServerOptions): void {
_defaultServer = new IsolationServer(opts);
_defaultServer.cleanup(); // startup cleanup
}
/** Handles any request to /__om_isolation__/* using the initialised server. */
export async 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);
): Promise<void> {
if (!_defaultServer) {
// Not yet initialised — return the stub response
res.writeHead(503, { 'content-type': 'text/html; charset=utf-8' });
res.end('<body style="font:13px monospace;padding:24px">Isolation server not initialised — call initIsolationServer() first.</body>');
return;
}
return _defaultServer.handleRequest(req, res);
}