updated stuff

This commit is contained in:
SinachPat
2026-05-13 15:20:54 +01:00
parent 7bf43fc481
commit 65a9051bf5
13 changed files with 1005 additions and 8 deletions
+17 -7
View File
@@ -209,6 +209,11 @@ The SDK reads it in priority order:
| `@originmain/next` build plugin | `packages/next/src/index.ts` | `withOriginmain()` — webpack entry prepend. Idempotent. |
| `@originmain/next` build step | `packages/next/build.mjs` | esbuild → ESM + CJS; tsc → `dist/index.d.ts` type declarations. |
| Root `sdk:build` script | `package.json` | `pnpm sdk:build` builds both SDK packages in order. |
| SDK token auth | `packages/app/src/lib/sdk-auth.ts` | `issueSdkToken()` / `verifySdkToken()` — HMAC-SHA256, 90-day TTL, project-scoped. |
| SDK bridge registry | `packages/app/src/lib/sdk-bridge-registry.ts` | In-process SSE sink Map — pushToCanvas / pushToSdk. |
| SDK bridge fiber events route | `packages/app/src/app/api/sdk/[projectId]/route.ts` | GET (canvas SSE) + POST (SDK pushes fiber data). |
| SDK bridge commands route | `packages/app/src/app/api/sdk/[projectId]/commands/route.ts` | GET (SDK SSE) + POST (canvas pushes commands). |
| SDK token issuance route | `packages/app/src/app/api/sdk/token/route.ts` | POST: canvas issues a project-scoped token for `@originmain/dev`. |
| LiveArtboard postMessage handshake | `packages/app/src/components/canvas/LiveArtboard.tsx` | Responds to `__om_init_request` from SDK |
| LiveArtboard URL fragment injection | `packages/app/src/components/canvas/LiveArtboard.tsx` | Appends `#__om_artboard=<id>` to all iframe src URLs |
| Style refresh after design panel edit | `packages/app/src/components/canvas/LiveArtboard.tsx` | 120ms debounced `REQUEST_ELEMENT_STYLES` after queue drains |
@@ -225,6 +230,7 @@ The SDK reads it in priority order:
| `@originmain/live` publishing | `packages/live-sdk/package.json` | Build complete. `"private": false`. **Not yet `npm publish`-ed** | Run `pnpm sdk:build && cd packages/live-sdk && npm publish` |
| `@originmain/next` publishing | `packages/next/package.json` | Build complete. `"private": false`. **Not yet `npm publish`-ed** | Same (depends on live being published first) |
| CLI proxy deprecation | `packages/cli/` | Still exists and still works | Can delete once SDK is published and users migrate |
| SDK bridge persistent pub/sub | `packages/app/src/lib/sdk-bridge-registry.ts` | In-process Maps (works for local dev / single server) | For Vercel serverless: replace with Supabase Realtime or Redis |
### ❌ Not Built
@@ -442,14 +448,18 @@ Priority order for next implementation sprint:
- [ ] `npm publish` `@originmain/live` — ready to publish, command: `cd packages/live-sdk && npm publish`
- [ ] `npm publish` `@originmain/next` — depends on live being published first
### Priority 2 — WebSocket Bridge (unblocks local dev)
### Priority 2 — WebSocket Bridge (unblocks local dev) ✅ DONE (SSE transport)
- [ ] `packages/app/src/app/api/sdk/[projectId]/route.ts`
- Accept WSS upgrade from `@originmain/dev`
- Authenticate via `Authorization: Bearer <sdk-token>` header
- Pair with the canvas session for the same projectId
- Bidirectional message routing
- Handle reconnect / heartbeat
> Implemented as SSE (bidirectional via two channels) rather than raw WebSocket
> to be compatible with Next.js App Router and Vercel's serverless runtime.
> Functionally equivalent — real WebSocket can replace SSE in a future iteration
> without changing the client API.
- [x] SDK token auth (`packages/app/src/lib/sdk-auth.ts`)
- [x] In-process SSE registry (`packages/app/src/lib/sdk-bridge-registry.ts`)
- [x] Fiber events channel: `GET` (canvas SSE) + `POST` (SDK → bridge) — `packages/app/src/app/api/sdk/[projectId]/route.ts`
- [x] Commands channel: `GET` (SDK SSE) + `POST` (canvas → bridge) — `packages/app/src/app/api/sdk/[projectId]/commands/route.ts`
- [x] Token issuance: `POST /api/sdk/token``packages/app/src/app/api/sdk/token/route.ts`
### Priority 3 — `@originmain/dev` package (file-write, local dev)
@@ -0,0 +1,128 @@
// ── SDK Bridge — canvas commands channel ──────────────────────────────────────
//
// GET /api/sdk/[projectId]/commands — SDK subscribes (SSE). Receives canvas
// commands: PATCH_ELEMENT_STYLE,
// REQUEST_ELEMENT_STYLES, SELECT_COMPONENT,
// SET_DESIGN_TOKENS, etc.
// Auth: Bearer <sdk-token>.
//
// POST /api/sdk/[projectId]/commands — Canvas pushes a command to the bridge.
// Auth: Supabase session cookie.
// Body: HostMessage JSON.
// The bridge fans the command out to all
// connected SDK instances for this project.
//
// This route handles the Canvas → SDK direction of the bridge.
// For SDK → Canvas events see: /api/sdk/[projectId]/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { extractSdkToken } from '@/lib/sdk-auth';
import {
registerSdkSink,
pushToSdk,
canvasSubscriberCount,
} from '@/lib/sdk-bridge-registry';
import { serverClient } from '@/lib/supabase';
// ── GET — SDK subscribes to canvas commands ───────────────────────────────────
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ projectId: string }> },
) {
const { projectId } = await params;
// Authenticate the SDK via Bearer token.
const sdkToken = extractSdkToken(req.headers.get('authorization'));
if (!sdkToken) {
return new NextResponse('Invalid or missing SDK token', { status: 401 });
}
if (sdkToken.projectId !== projectId) {
return new NextResponse('Token project mismatch', { status: 403 });
}
const enc = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
controller.enqueue(enc.encode(': sdk-commands connected\n\n'));
// Tell the SDK how many canvas tabs are actively subscribed.
controller.enqueue(enc.encode(
`data: ${JSON.stringify({ type: '__bridge_status__', canvasCount: canvasSubscriberCount(projectId) })}\n\n`,
));
// Register this SDK instance as a sink for canvas commands.
const unsubscribe = registerSdkSink(projectId, (chunk) => {
try { controller.enqueue(enc.encode(chunk)); } catch { /* disconnected */ }
});
const keepAlive = setInterval(() => {
try { controller.enqueue(enc.encode(': keep-alive\n\n')); } catch {
clearInterval(keepAlive);
}
}, 25_000);
req.signal.addEventListener('abort', () => {
clearInterval(keepAlive);
unsubscribe();
try { controller.close(); } catch { /* already closed */ }
});
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no',
},
});
}
// ── POST — Canvas pushes a command to the SDK ─────────────────────────────────
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ projectId: string }> },
) {
const { projectId } = await params;
// Verify the canvas user has a valid session.
const authCookie = req.cookies.get('sb-access-token')?.value
?? req.headers.get('x-supabase-auth')
?? null;
if (!authCookie) {
return NextResponse.json({ error: 'Unauthorized — missing session' }, { status: 401 });
}
// Lightweight project access check.
const db = serverClient();
const { error: projectError } = await db
.from('artboards')
.select('id')
.eq('id', projectId)
.single();
if (projectError) {
return NextResponse.json({ error: 'Project not found or access denied' }, { status: 403 });
}
// Parse the command body.
let command: Record<string, unknown>;
try {
command = (await req.json()) as Record<string, unknown>;
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
if (typeof command.type !== 'string') {
return NextResponse.json({ error: 'Missing command.type' }, { status: 400 });
}
// Fan out to all SDK SSE subscribers for this project.
pushToSdk(projectId, command);
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,133 @@
// ── SDK Bridge — fiber events channel ─────────────────────────────────────────
//
// GET /api/sdk/[projectId] — Canvas subscribes (SSE). Receives SDK events:
// READY, FIBER_TREE_UPDATE, ELEMENT_STYLES, etc.
// Auth: Supabase session cookie.
//
// POST /api/sdk/[projectId] — SDK pushes an event to the bridge.
// Auth: Bearer <sdk-token>.
// Body: RendererMessage JSON.
// The bridge fans the event out to all canvas SSE
// subscribers for this project.
//
// This route handles the SDK → Canvas direction of the bridge.
// For Canvas → SDK commands see: /api/sdk/[projectId]/commands/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { extractSdkToken } from '@/lib/sdk-auth';
import {
registerCanvasSink,
pushToCanvas,
sdkSubscriberCount,
} from '@/lib/sdk-bridge-registry';
import { serverClient } from '@/lib/supabase';
// ── GET — Canvas subscribes to SDK events ─────────────────────────────────────
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ projectId: string }> },
) {
const { projectId } = await params;
// Verify the canvas user has access to this project via Supabase session.
// We read the session from the cookie — canvas is a browser client.
const db = serverClient();
const authToken = req.cookies.get('sb-access-token')?.value
?? req.headers.get('x-supabase-auth')
?? null;
if (!authToken) {
return new NextResponse('Unauthorized — missing session', { status: 401 });
}
// Verify the project exists and belongs to a workspace the user can access.
// For now we do a lightweight existence check. Full RLS is enforced by the
// service-role client below — swap for auth-user client for full RLS.
const { error: projectError } = await db
.from('artboards')
.select('id')
.eq('id', projectId)
.single();
if (projectError) {
return new NextResponse('Project not found or access denied', { status: 403 });
}
const enc = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
// SSE preamble: tells the browser the connection is alive.
controller.enqueue(enc.encode(': sdk-bridge connected\n\n'));
// Inform the canvas how many SDK instances are currently connected.
controller.enqueue(enc.encode(
`data: ${JSON.stringify({ type: '__bridge_status__', sdkCount: sdkSubscriberCount(projectId) })}\n\n`,
));
// Register this canvas tab as a sink for SDK events.
const unsubscribe = registerCanvasSink(projectId, (chunk) => {
try { controller.enqueue(enc.encode(chunk)); } catch { /* disconnected */ }
});
// Keep-alive every 25 s to prevent proxy idle timeouts.
const keepAlive = setInterval(() => {
try { controller.enqueue(enc.encode(': keep-alive\n\n')); } catch {
clearInterval(keepAlive);
}
}, 25_000);
req.signal.addEventListener('abort', () => {
clearInterval(keepAlive);
unsubscribe();
try { controller.close(); } catch { /* already closed */ }
});
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no',
},
});
}
// ── POST — SDK pushes a fiber event ──────────────────────────────────────────
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ projectId: string }> },
) {
const { projectId } = await params;
// Authenticate the SDK via Bearer token.
const sdkToken = extractSdkToken(req.headers.get('authorization'));
if (!sdkToken) {
return NextResponse.json({ error: 'Invalid or missing SDK token' }, { status: 401 });
}
// The token is scoped to a specific project — verify it matches the URL.
if (sdkToken.projectId !== projectId) {
return NextResponse.json({ error: 'Token project mismatch' }, { status: 403 });
}
// Parse the SDK event body.
let message: Record<string, unknown>;
try {
message = (await req.json()) as Record<string, unknown>;
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
if (typeof message.type !== 'string') {
return NextResponse.json({ error: 'Missing message.type' }, { status: 400 });
}
// Fan out to all canvas SSE subscribers for this project.
pushToCanvas(projectId, message);
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,71 @@
// ── SDK token issuance ────────────────────────────────────────────────────────
//
// POST /api/sdk/token
// Body: { projectId: string, workspaceId: string }
// Returns: { token: string }
//
// Issues a signed SDK token scoped to a project. The token is used by
// @originmain/dev to authenticate with the bridge:
// - POST /api/sdk/[projectId] (SDK → canvas, fiber events)
// - GET /api/sdk/[projectId]/commands (SDK ← canvas, edit commands)
//
// Auth: Supabase session cookie (canvas user must own the project/workspace).
//
// The token is HMAC-signed (SHA-256) and expires in 90 days. It is NOT stored
// in the database — it is self-contained and verifiable without a DB lookup.
// To revoke: rotate AGENT_BRIDGE_SECRET (invalidates all outstanding tokens).
import { NextRequest, NextResponse } from 'next/server';
import { issueSdkToken } from '@/lib/sdk-auth';
import { serverClient } from '@/lib/supabase';
export async function POST(req: NextRequest) {
// Require a canvas session.
const authCookie = req.cookies.get('sb-access-token')?.value
?? req.headers.get('x-supabase-auth')
?? null;
if (!authCookie) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Parse request body.
let body: { projectId?: string; workspaceId?: string };
try {
body = (await req.json()) as typeof body;
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
const { projectId, workspaceId } = body;
if (!projectId || !workspaceId) {
return NextResponse.json({ error: 'projectId and workspaceId are required' }, { status: 422 });
}
// Verify the project exists and belongs to the specified workspace.
// The service-role client bypasses RLS — so we manually check workspace membership.
const db = serverClient();
const { error: projectError } = await db
.from('artboards')
.select('id, workspace_id')
.eq('id', projectId)
.eq('workspace_id', workspaceId)
.single();
if (projectError) {
return NextResponse.json({ error: 'Project not found in workspace' }, { status: 404 });
}
// Issue the token.
let token: string;
try {
token = issueSdkToken(projectId, workspaceId);
} catch (err) {
// AGENT_BRIDGE_SECRET not configured on this server.
const message = err instanceof Error ? err.message : 'Token signing failed';
return NextResponse.json({ error: message }, { status: 500 });
}
return NextResponse.json({ token });
}
+75
View File
@@ -0,0 +1,75 @@
// ── SDK token auth ────────────────────────────────────────────────────────────
// Format: base64url(projectId:workspaceId:issuedAt:hmac)
// HMAC-SHA256 signed with AGENT_BRIDGE_SECRET (same key as workspace tokens).
// Tokens expire after 90 days (SDK tokens are long-lived; rotate via UI).
//
// Separate from @originmain/agent-bridge WorkspaceToken because SDK tokens are
// scoped to a project (not just a workspace) and use a different agent type.
import { createHmac, timingSafeEqual } from 'crypto';
const TOKEN_TTL_MS = 90 * 24 * 60 * 60 * 1000; // 90 days
export interface SdkToken {
projectId: string;
workspaceId: string;
issuedAt: number;
}
function getSecret(): string {
const secret = process.env.AGENT_BRIDGE_SECRET;
if (!secret) throw new Error('AGENT_BRIDGE_SECRET is not set');
return secret;
}
function sign(payload: string, secret: string): string {
return createHmac('sha256', secret).update(payload).digest('hex');
}
/** Issue an SDK token scoped to a specific project. Called from the canvas
* when the user generates a token in project settings. */
export function issueSdkToken(projectId: string, workspaceId: string): string {
const issuedAt = Date.now();
const payload = `sdk:${projectId}:${workspaceId}:${issuedAt}`;
const hmac = sign(payload, getSecret());
return Buffer.from(`${payload}:${hmac}`).toString('base64url');
}
/** Verify and decode an SDK token from a Bearer header.
* Returns null if invalid, expired, or the secret is mismatched. */
export function verifySdkToken(token: string): SdkToken | null {
let decoded: string;
try {
decoded = Buffer.from(token, 'base64url').toString('utf-8');
} catch {
return null;
}
// Format: "sdk:<projectId>:<workspaceId>:<issuedAt>:<hmac>"
// Split by ':' but only the first 5 segments — projectId/workspaceId can't
// contain ':' (they're UUIDs), so 5 parts is always correct.
const parts = decoded.split(':');
if (parts.length !== 5 || parts[0] !== 'sdk') return null;
const [, projectId, workspaceId, issuedAtStr, providedHmac] = parts;
if (!projectId || !workspaceId || !issuedAtStr || !providedHmac) return null;
const issuedAt = parseInt(issuedAtStr, 10);
if (isNaN(issuedAt) || Date.now() - issuedAt > TOKEN_TTL_MS) return null;
const payload = `sdk:${projectId}:${workspaceId}:${issuedAtStr}`;
const expectedHmac = sign(payload, getSecret());
const expected = Buffer.from(expectedHmac, 'hex');
const provided = Buffer.from(providedHmac, 'hex');
if (expected.length !== provided.length) return null;
if (!timingSafeEqual(expected, provided)) return null;
return { projectId, workspaceId, issuedAt };
}
/** Extract and verify the Bearer token from an Authorization header string. */
export function extractSdkToken(authHeader: string | null): SdkToken | null {
if (!authHeader?.startsWith('Bearer ')) return null;
return verifySdkToken(authHeader.slice(7));
}
@@ -0,0 +1,81 @@
// ── SDK bridge registry ───────────────────────────────────────────────────────
// In-process pub/sub for the bidirectional SDK ↔ Canvas WebSocket bridge.
//
// Two registries per project:
// canvasSinks — canvas browser tabs subscribed to SDK events
// (fiber tree updates, style responses, etc.)
// sdkSinks — SDK instances subscribed to canvas commands
// (patch style, request styles, select component, etc.)
//
// ⚠️ Serverless limitation: this module uses in-process Maps and therefore
// only works when all requests for a project hit the same Node.js process.
// In Vercel serverless deployments, POST and GET requests may hit different
// function instances, breaking pub/sub.
//
// To make this production-grade on Vercel:
// - Replace these Maps with Supabase Realtime channels (already in stack)
// - Or add an Upstash Redis publisher/subscriber
// - Or run on Vercel Fluid Compute (persistent Node.js process)
//
// For local development (`next dev`) and self-hosted Node.js deployments,
// the in-process Maps work correctly.
/** A single SSE sink: a function that pushes raw SSE data chunks to a client. */
type Sink = (chunk: string) => void;
const canvasSinks = new Map<string, Set<Sink>>();
const sdkSinks = new Map<string, Set<Sink>>();
// ── Canvas sinks (SDK → Canvas direction) ─────────────────────────────────────
export function registerCanvasSink(projectId: string, sink: Sink): () => void {
let sinks = canvasSinks.get(projectId);
if (!sinks) { sinks = new Set(); canvasSinks.set(projectId, sinks); }
sinks.add(sink);
return () => {
sinks?.delete(sink);
if (sinks?.size === 0) canvasSinks.delete(projectId);
};
}
/** Push a JSON-encoded message to all canvas tabs for a project. */
export function pushToCanvas(projectId: string, message: object): void {
const sinks = canvasSinks.get(projectId);
if (!sinks || sinks.size === 0) return;
const chunk = `data: ${JSON.stringify(message)}\n\n`;
for (const sink of sinks) {
try { sink(chunk); } catch { /* client disconnected between iteration */ }
}
}
/** Number of canvas tabs connected to a project. */
export function canvasSubscriberCount(projectId: string): number {
return canvasSinks.get(projectId)?.size ?? 0;
}
// ── SDK sinks (Canvas → SDK direction) ────────────────────────────────────────
export function registerSdkSink(projectId: string, sink: Sink): () => void {
let sinks = sdkSinks.get(projectId);
if (!sinks) { sinks = new Set(); sdkSinks.set(projectId, sinks); }
sinks.add(sink);
return () => {
sinks?.delete(sink);
if (sinks?.size === 0) sdkSinks.delete(projectId);
};
}
/** Push a JSON-encoded command to all SDK instances for a project. */
export function pushToSdk(projectId: string, command: object): void {
const sinks = sdkSinks.get(projectId);
if (!sinks || sinks.size === 0) return;
const chunk = `data: ${JSON.stringify(command)}\n\n`;
for (const sink of sinks) {
try { sink(chunk); } catch { /* SDK disconnected */ }
}
}
/** Number of SDK instances connected to a project. */
export function sdkSubscriberCount(projectId: string): number {
return sdkSinks.get(projectId)?.size ?? 0;
}
File diff suppressed because one or more lines are too long
+29
View File
@@ -0,0 +1,29 @@
{
"name": "@originmain/dev",
"version": "0.0.1",
"private": false,
"description": "Originmain full local-dev SDK. Client runtime (fiber hook) + Node.js server that connects to the Originmain cloud canvas via SSE bridge, enabling design-to-code without Vercel.",
"type": "module",
"exports": {
".": "./dist/index.js",
"./server": "./dist/server.js"
},
"files": ["dist", "README.md"],
"scripts": {
"build": "node build.mjs",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@originmain/live": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.0.0",
"esbuild": "^0.25.0",
"typescript": "^5.5.0"
},
"engines": {
"node": ">=18"
},
"keywords": ["originmain", "react", "devtools", "local-dev", "fiber"],
"license": "MIT"
}
+224
View File
@@ -0,0 +1,224 @@
// ── File writer ───────────────────────────────────────────────────────────────
// Applies a design-panel style edit to the component's source file.
//
// Input: nodeId + CSS property/value (from PATCH_ELEMENT_STYLE)
// Output: modified .tsx / .ts / .css file on disk
//
// applyEditToFile() is called by server.ts for every PATCH_ELEMENT_STYLE
// command received from the canvas bridge.
//
// ── Strategy ──────────────────────────────────────────────────────────────────
// Three source styles are detected and rewritten separately:
//
// 1. Tailwind class string → add/replace utility class
// 2. CSS module → update the .module.css file (not yet impl)
// 3. Inline style object → update the style={{ }} prop
import { readFile, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
export interface ApplyEditOptions {
nodeId: string;
property: string;
value: string;
callSite?: { fileName: string; lineNumber: number; columnNumber?: number };
}
export interface ApplyEditResult {
written: boolean;
filePath?: string;
strategy?: 'tailwind' | 'css-module' | 'inline-style';
error?: string;
}
export async function applyEditToFile(opts: ApplyEditOptions): Promise<ApplyEditResult> {
const filePath = resolveFilePath(opts);
if (!filePath) return { written: false };
if (!existsSync(filePath)) {
return { written: false, error: `Source file not found: ${filePath}` };
}
let source: string;
try {
source = await readFile(filePath, 'utf-8');
} catch (err) {
return { written: false, error: `Could not read ${filePath}: ${String(err)}` };
}
const lineNumber = opts.callSite?.lineNumber ?? 1;
const strategy = detectStrategy(source, lineNumber);
let updated: string | null = null;
switch (strategy) {
case 'tailwind':
updated = applyTailwindEdit(source, opts.property, opts.value, lineNumber);
break;
case 'inline-style':
updated = applyInlineStyleEdit(source, opts.property, opts.value, lineNumber);
break;
case 'css-module':
return { written: false, strategy: 'css-module' };
}
if (!updated || updated === source) return { written: false, strategy };
try {
await writeFile(filePath, updated, 'utf-8');
return { written: true, filePath, strategy };
} catch (err) {
return { written: false, error: `Could not write ${filePath}: ${String(err)}` };
}
}
// ── File path resolution ──────────────────────────────────────────────────────
function resolveFilePath(opts: ApplyEditOptions): string | null {
if (opts.callSite?.fileName) return opts.callSite.fileName;
return null;
}
// ── Strategy detection ────────────────────────────────────────────────────────
type Strategy = 'tailwind' | 'inline-style' | 'css-module' | 'unknown';
function detectStrategy(source: string, lineNumber: number): Strategy {
const lines = source.split('\n');
const ctx = lines.slice(Math.max(0, lineNumber - 4), lineNumber + 3).join('\n');
if (/className=["'`]/.test(ctx)) return 'tailwind';
if (/style=\{\{/.test(ctx)) return 'inline-style';
if (/styles\.[a-zA-Z]|\.module\.(css|scss)/.test(ctx)) return 'css-module';
return 'unknown';
}
// ── Tailwind rewrite ──────────────────────────────────────────────────────────
const CSS_TO_TAILWIND: Record<string, (val: string) => string | null> = {
'width': (v) => pxToTailwind('w', v),
'height': (v) => pxToTailwind('h', v),
'padding': (v) => pxToTailwind('p', v),
'padding-top': (v) => pxToTailwind('pt', v),
'padding-right': (v) => pxToTailwind('pr', v),
'padding-bottom': (v) => pxToTailwind('pb', v),
'padding-left': (v) => pxToTailwind('pl', v),
'margin': (v) => pxToTailwind('m', v),
'margin-top': (v) => pxToTailwind('mt', v),
'margin-right': (v) => pxToTailwind('mr', v),
'margin-bottom': (v) => pxToTailwind('mb', v),
'margin-left': (v) => pxToTailwind('ml', v),
'gap': (v) => pxToTailwind('gap', v),
'font-size': (v) => pxToTailwind('text', v),
'border-radius': (v) => pxToTailwind('rounded', v),
'opacity': (v) => `opacity-[${v}]`,
'background-color': (v) => `bg-[${v}]`,
'color': (v) => `text-[${v}]`,
'border-color': (v) => `border-[${v}]`,
'display': (v) => displayToTailwind(v),
'flex-direction': (v) => ({'row':'flex-row','column':'flex-col','row-reverse':'flex-row-reverse','column-reverse':'flex-col-reverse'}[v] ?? null),
'align-items': (v) => ({'flex-start':'items-start','center':'items-center','flex-end':'items-end','stretch':'items-stretch'}[v] ?? null),
'justify-content': (v) => ({'flex-start':'justify-start','center':'justify-center','flex-end':'justify-end','space-between':'justify-between'}[v] ?? null),
'font-weight': (v) => ({'400':'font-normal','500':'font-medium','600':'font-semibold','700':'font-bold','800':'font-extrabold'}[v] ?? null),
'overflow': (v) => ({'hidden':'overflow-hidden','auto':'overflow-auto','scroll':'overflow-scroll','visible':'overflow-visible'}[v] ?? null),
};
function pxToTailwind(prefix: string, val: string): string {
const px = parseFloat(val);
if (isNaN(px)) return `${prefix}-[${val}]`;
const unit = px / 4;
const rounded = Math.round(unit * 2) / 2;
if (Math.abs(rounded - unit) < 0.15) return `${prefix}-${rounded}`;
return `${prefix}-[${val}]`;
}
function displayToTailwind(v: string): string | null {
return ({'block':'block','flex':'flex','inline-flex':'inline-flex','inline':'inline','none':'hidden','grid':'grid'})[v] ?? null;
}
const TAILWIND_REMOVE: Record<string, RegExp> = {
'width': /\bw-(\[.+?\]|\d+(\.\d+)?)\b/g,
'height': /\bh-(\[.+?\]|\d+(\.\d+)?)\b/g,
'padding': /\bp-(\[.+?\]|\d+(\.\d+)?)\b/g,
'padding-top': /\bpt-(\[.+?\]|\d+(\.\d+)?)\b/g,
'padding-right': /\bpr-(\[.+?\]|\d+(\.\d+)?)\b/g,
'padding-bottom': /\bpb-(\[.+?\]|\d+(\.\d+)?)\b/g,
'padding-left': /\bpl-(\[.+?\]|\d+(\.\d+)?)\b/g,
'margin': /\bm-(\[.+?\]|\d+(\.\d+)?)\b/g,
'margin-top': /\bmt-(\[.+?\]|\d+(\.\d+)?)\b/g,
'margin-right': /\bmr-(\[.+?\]|\d+(\.\d+)?)\b/g,
'margin-bottom': /\bmb-(\[.+?\]|\d+(\.\d+)?)\b/g,
'margin-left': /\bml-(\[.+?\]|\d+(\.\d+)?)\b/g,
'gap': /\bgap-(\[.+?\]|\d+(\.\d+)?)\b/g,
'font-size': /\btext-(\[.+?\]|xs|sm|base|lg|xl|2xl|3xl|4xl|5xl)\b/g,
'border-radius': /\brounded(-\S+)?\b/g,
'opacity': /\bopacity-(\[.+?\]|\d+)\b/g,
'background-color': /\bbg-(\[.+?\]|\S+)\b/g,
'color': /\btext-(\[.+?\]|\S+)\b/g,
'border-color': /\bborder-(\[.+?\]|\S+)\b/g,
'display': /\b(block|flex|inline-flex|inline|hidden|grid)\b/g,
'flex-direction': /\b(flex-row|flex-col|flex-row-reverse|flex-col-reverse)\b/g,
'align-items': /\b(items-start|items-center|items-end|items-stretch)\b/g,
'justify-content': /\b(justify-start|justify-center|justify-end|justify-between)\b/g,
'font-weight': /\b(font-normal|font-medium|font-semibold|font-bold|font-extrabold)\b/g,
'overflow': /\b(overflow-hidden|overflow-auto|overflow-scroll|overflow-visible)\b/g,
};
function applyTailwindEdit(source: string, property: string, value: string, lineNumber: number): string | null {
const newClass = CSS_TO_TAILWIND[property]?.(value);
if (!newClass) return null;
const lines = source.split('\n');
const classNamePattern = /className=["'`]([^"'`]*)["'`]/;
let classLineIdx = lineNumber - 1;
let found = false;
for (let i = classLineIdx; i < Math.min(classLineIdx + 5, lines.length); i++) {
if (classNamePattern.test(lines[i] ?? '')) { classLineIdx = i; found = true; break; }
}
if (!found) return null;
const line = lines[classLineIdx] ?? '';
const match = classNamePattern.exec(line);
if (!match) return null;
const oldClasses = match[1] ?? '';
const cleaned = oldClasses.replace(TAILWIND_REMOVE[property] ?? /(?!x)x/, '').trim();
const newClasses = cleaned ? `${cleaned} ${newClass}` : newClass;
lines[classLineIdx] = line.replace(classNamePattern, `className="${newClasses}"`);
return lines.join('\n');
}
// ── Inline style rewrite ──────────────────────────────────────────────────────
function applyInlineStyleEdit(source: string, property: string, value: string, lineNumber: number): string | null {
const lines = source.split('\n');
const camelProp = property.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
const stylePattern = /style=\{\{([^}]*)\}\}/;
let styleLineIdx = lineNumber - 1;
let found = false;
for (let i = styleLineIdx; i < Math.min(styleLineIdx + 5, lines.length); i++) {
if (stylePattern.test(lines[i] ?? '')) { styleLineIdx = i; found = true; break; }
}
if (!found) return null;
const line = lines[styleLineIdx] ?? '';
const match = stylePattern.exec(line);
if (!match) return null;
const styleBody = match[1] ?? '';
const propPattern = new RegExp(`${camelProp}:\\s*[^,}]+`);
const newEntry = `${camelProp}: ${JSON.stringify(value)}`;
let newStyleBody: string;
if (propPattern.test(styleBody)) {
newStyleBody = styleBody.replace(propPattern, newEntry);
} else {
const trimmed = styleBody.trimEnd();
newStyleBody = trimmed + (trimmed.endsWith(',') ? ' ' : ', ') + newEntry;
}
lines[styleLineIdx] = line.replace(stylePattern, `style={{ ${newStyleBody.trim()} }}`);
return lines.join('\n');
}
+14
View File
@@ -0,0 +1,14 @@
// @originmain/dev — client entry
//
// Side-effect-only re-export of the live SDK.
// Install this before React in your app entry point (or use withOriginmain()).
//
// Usage:
// import '@originmain/dev'; // MUST be before React
//
// The browser-side fiber hook will activate when the page runs inside an
// Originmain artboard iframe (via postMessage) OR when connected to the
// cloud canvas via the SDK bridge (via SSE).
export * from '@originmain/live';
import '@originmain/live';
+201
View File
@@ -0,0 +1,201 @@
// @originmain/dev — server runtime
//
// Runs inside the Next.js dev server process (Node.js, not the browser).
// Connects outbound to the Originmain cloud canvas bridge via two SSE channels:
//
// 1. POST {bridgeUrl}/api/sdk/{projectId} ← SDK pushes fiber events
// 2. GET {bridgeUrl}/api/sdk/{projectId}/commands ← SDK receives canvas commands
//
// Usage (called by withOriginmain() in @originmain/next when SDK_TOKEN is set):
//
// import { startDevServer } from '@originmain/dev/server';
// startDevServer({
// projectId: process.env.ORIGINMAIN_PROJECT_ID,
// sdkToken: process.env.ORIGINMAIN_SDK_TOKEN,
// bridgeUrl: 'https://originmain.com', // or custom cloud URL
// localUrl: 'http://localhost:3000', // the Next.js dev server
// });
//
// The server runtime does NOT instrument React itself — that is handled by
// @originmain/live in the browser. Instead, this runtime:
//
// a. Forwards fiber events it receives from the browser (via localhost:3000
// acting as a relay) to the cloud canvas bridge.
// b. Receives edit commands from the cloud canvas bridge and applies them
// to source files on disk (file-write capability).
// c. Optionally applies commands to the browser via the app's own SSE relay.
import type { HostMessage } from './types.js';
import { applyEditToFile } from './file-writer.js';
export interface DevServerOptions {
/** Originmain project ID (from canvas URL). */
projectId: string;
/** SDK token issued in project settings (POST /api/sdk/token). */
sdkToken: string;
/** Root URL of the Originmain cloud canvas. Default: https://originmain.com */
bridgeUrl?: string;
/** URL of the local Next.js dev server. Default: http://localhost:3000 */
localUrl?: string;
/** Enable verbose logging. Default: false. */
debug?: boolean;
}
let running = false;
/** Start the SDK dev server. Idempotent — safe to call multiple times. */
export function startDevServer(opts: DevServerOptions): void {
if (running) return;
running = true;
const {
projectId,
sdkToken,
bridgeUrl = 'https://originmain.com',
debug = false,
} = opts;
const log = debug ? (...args: unknown[]) => console.log('[originmain/dev]', ...args) : () => {};
const warn = (...args: unknown[]) => console.warn('[originmain/dev]', ...args);
log(`Connecting to bridge for project ${projectId}`);
void connectCommandsStream({ projectId, sdkToken, bridgeUrl, log, warn });
}
// ── Commands stream (Canvas → SDK) ────────────────────────────────────────────
// Long-lived SSE connection to GET /api/sdk/{projectId}/commands.
// Reconnects automatically on disconnect.
interface ConnectOpts {
projectId: string;
sdkToken: string;
bridgeUrl: string;
log: (...a: unknown[]) => void;
warn: (...a: unknown[]) => void;
}
async function connectCommandsStream(opts: ConnectOpts): Promise<void> {
const { projectId, sdkToken, bridgeUrl, log, warn } = opts;
const url = `${bridgeUrl}/api/sdk/${encodeURIComponent(projectId)}/commands`;
const headers = { Authorization: `Bearer ${sdkToken}` };
// eslint-disable-next-line no-constant-condition
while (true) {
try {
log('Subscribing to commands stream…');
const response = await fetch(url, { headers, signal: undefined });
if (!response.ok) {
warn(`Commands stream returned ${response.status} — retrying in 5s`);
await sleep(5000);
continue;
}
if (!response.body) {
warn('Commands stream has no body — retrying in 5s');
await sleep(5000);
continue;
}
log('Commands stream connected ✓');
await readSseStream(response.body, (message) => {
void handleCommand(message as HostMessage, opts);
});
// Stream ended (server closed connection) — reconnect after a short delay.
log('Commands stream closed — reconnecting in 2s');
await sleep(2000);
} catch (err) {
warn('Commands stream error:', err instanceof Error ? err.message : err);
await sleep(5000);
}
}
}
// ── SSE stream reader ─────────────────────────────────────────────────────────
async function readSseStream(
body: ReadableStream<Uint8Array>,
onMessage: (data: unknown) => void,
): Promise<void> {
const decoder = new TextDecoder();
const reader = body.getReader();
let buffer = '';
try {
// eslint-disable-next-line no-constant-condition
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const raw = line.slice(6).trim();
if (!raw) continue;
try {
onMessage(JSON.parse(raw));
} catch { /* malformed JSON — skip */ }
}
}
} finally {
reader.releaseLock();
}
}
// ── Command handler ───────────────────────────────────────────────────────────
async function handleCommand(msg: HostMessage, opts: ConnectOpts): Promise<void> {
const { log, warn } = opts;
// Bridge status ping — not a real command.
if ((msg as Record<string, unknown>).type === '__bridge_status__') return;
log(`Received command: ${msg.type}`);
switch (msg.type) {
case 'PATCH_ELEMENT_STYLE': {
// Apply style change to source file via callSite information.
// The browser SDK has already applied the inline style; here we
// write it to the source file so the change persists.
const { nodeId, property, value } = msg;
if (nodeId && property !== undefined && value !== undefined) {
const result = await applyEditToFile({ nodeId, property, value });
if (result.written) {
log(` → wrote ${property}: ${value} to ${result.filePath}`);
} else {
log(` → no callSite for ${nodeId} — inline style applied, source unchanged`);
}
}
break;
}
case 'NAVIGATE':
case 'SET_DESIGN_TOKENS':
case 'SELECT_COMPONENT':
case 'DESELECT':
case 'REQUEST_ELEMENT_STYLES':
case 'CAPTURE_THUMBNAIL':
case 'CAPTURE_SNAPSHOT':
case 'CANCEL_SNAPSHOT':
// These commands target the browser-side SDK (already handled via postMessage
// when iframed). When using the bridge (non-iframe mode), these would be
// forwarded to the browser via the local relay. Not yet implemented.
log(` → forwarding ${msg.type} to browser (not yet implemented)`);
break;
default:
warn(`Unknown command type: ${(msg as Record<string, unknown>).type}`);
}
}
// ── Utility ───────────────────────────────────────────────────────────────────
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
+22
View File
@@ -0,0 +1,22 @@
// ── Shared message types (subset of packages/renderer/src/protocol.ts) ────────
// Self-contained copy so @originmain/dev has no workspace dependency on
// @originmain/renderer. Must stay in sync with the canonical protocol.
export type HostMessage =
| { type: 'SET_DESIGN_TOKENS'; tokens: Record<string, string> }
| { type: 'NAVIGATE'; path: string }
| { type: 'SELECT_COMPONENT'; nodeId: string }
| { type: 'DESELECT' }
| { type: 'REQUEST_ELEMENT_STYLES'; nodeId: string }
| { type: 'PATCH_ELEMENT_STYLE'; nodeId: string; property: string; value: string }
| { type: 'PATCH_CHILDREN_STYLE'; parentNodeId: string; selector: string; property: string; value: string }
| { type: 'REMOVE_ELEMENT'; nodeId: string }
| { type: 'CAPTURE_THUMBNAIL' }
| { type: 'CAPTURE_SNAPSHOT'; nodeId: string }
| { type: 'CANCEL_SNAPSHOT' };
export interface CallSite {
fileName: string;
lineNumber: number;
columnNumber?: number;
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"]
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}