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 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. |
|
| `@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. |
|
| 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 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 |
|
| 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 |
|
| 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/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) |
|
| `@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 |
|
| 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
|
### ❌ 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/live` — ready to publish, command: `cd packages/live-sdk && npm publish`
|
||||||
- [ ] `npm publish` `@originmain/next` — depends on live being published first
|
- [ ] `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`
|
> Implemented as SSE (bidirectional via two channels) rather than raw WebSocket
|
||||||
- Accept WSS upgrade from `@originmain/dev`
|
> to be compatible with Next.js App Router and Vercel's serverless runtime.
|
||||||
- Authenticate via `Authorization: Bearer <sdk-token>` header
|
> Functionally equivalent — real WebSocket can replace SSE in a future iteration
|
||||||
- Pair with the canvas session for the same projectId
|
> without changing the client API.
|
||||||
- Bidirectional message routing
|
|
||||||
- Handle reconnect / heartbeat
|
- [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)
|
### 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