updated stuff
This commit is contained in:
+17
-7
@@ -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 });
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user