Files
jhonny-editor/code/src/bridge/uxp-websocket-bridge.ts
T
João Henrique b541f502ba feat: initial commit - Jhonny Editor
- Adicionado estrutura completa do projeto
- Configurado MCP server para Premiere Pro
- Adicionado documentação e skills
- Configurado Gitignore para o projeto
2026-09-08 09:59:31 -04:00

321 lines
10 KiB
TypeScript
Executable File

import { randomUUID, timingSafeEqual } from "node:crypto";
import { EventEmitter } from "node:events";
import { createServer, type Server } from "node:http";
import { WebSocket, WebSocketServer } from "ws";
const LOOPBACK_HOST = "127.0.0.1";
const SUPPORTED_PROTOCOLS = new Set([1, 2]);
export interface UxpBridgeOptions {
token: string;
port?: number;
path?: string;
requestTimeoutMs?: number;
handshakeTimeoutMs?: number;
}
export interface UxpCapability {
supported: boolean;
[key: string]: unknown;
}
export interface UxpRequestOptions {
/** Do not let the bridge timeout before a bounded host-side wait can settle. */
minimumTimeoutMs?: number;
}
export interface UxpHello {
backend: "uxp";
protocolVersion: number;
commands: Record<string, UxpCapability>;
[key: string]: unknown;
}
export type UxpConnectionState =
| { status: "stopped" | "listening"; connected: false }
| {
status: "connected";
connected: true;
protocolVersion: number;
capabilities: UxpHello;
connectedAt: string;
};
interface PendingRequest {
command: string;
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timer: NodeJS.Timeout;
}
export class UxpBridgeError extends Error {
constructor(
readonly code: string,
message: string,
) {
super(message);
this.name = "UxpBridgeError";
}
}
function secureTokenEqual(actual: string, expected: string): boolean {
const left = Buffer.from(actual);
const right = Buffer.from(expected);
return left.length === right.length && timingSafeEqual(left, right);
}
function validPort(value: number): number {
if (!Number.isInteger(value) || value < 0 || value > 65535) {
throw new Error("UXP bridge port must be an integer between 0 and 65535");
}
return value;
}
/**
* Authenticated loopback WebSocket server used only by the local Premiere UXP
* panel. It never binds a LAN/WAN interface and does not silently fall back to
* CEP after a UXP command has been sent.
*/
export class UxpWebSocketBridge extends EventEmitter {
private readonly options: Required<UxpBridgeOptions>;
private httpServer: Server | null = null;
private wsServer: WebSocketServer | null = null;
private socket: WebSocket | null = null;
private hello: UxpHello | null = null;
private connectedAt: string | null = null;
private handshakeTimer: NodeJS.Timeout | null = null;
private readonly pending = new Map<string, PendingRequest>();
constructor(options: UxpBridgeOptions) {
super();
if (!options.token || options.token.length < 16) {
throw new Error("PREMIERE_UXP_TOKEN must contain at least 16 characters");
}
this.options = {
token: options.token,
port: validPort(options.port ?? 7777),
path: options.path ?? "/uxp",
requestTimeoutMs: options.requestTimeoutMs ?? 30_000,
handshakeTimeoutMs: options.handshakeTimeoutMs ?? 5_000,
};
if (!this.options.path.startsWith("/")) {
throw new Error("UXP bridge path must begin with '/'");
}
}
async start(): Promise<void> {
if (this.httpServer) return;
const httpServer = createServer((_req, res) => {
res.writeHead(404, { "Content-Type": "text/plain" });
res.end("Not found");
});
const wsServer = new WebSocketServer({ noServer: true, maxPayload: 1_048_576 });
httpServer.on("upgrade", (request, socket, head) => {
const url = new URL(request.url ?? "/", `http://${LOOPBACK_HOST}`);
const authorized =
url.pathname === this.options.path &&
secureTokenEqual(url.searchParams.get("token") ?? "", this.options.token);
if (!authorized) {
socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
socket.destroy();
return;
}
wsServer.handleUpgrade(request, socket, head, (client) => {
wsServer.emit("connection", client, request);
});
});
wsServer.on("connection", (client) => this.acceptConnection(client));
await new Promise<void>((resolve, reject) => {
httpServer.once("error", reject);
httpServer.listen(this.options.port, LOOPBACK_HOST, () => {
httpServer.off("error", reject);
resolve();
});
});
this.httpServer = httpServer;
this.wsServer = wsServer;
this.emit("listening", this.address());
}
address(): { host: string; port: number; path: string } {
const address = this.httpServer?.address();
return {
host: LOOPBACK_HOST,
port: typeof address === "object" && address ? address.port : this.options.port,
path: this.options.path,
};
}
getState(): UxpConnectionState {
if (this.socket?.readyState === WebSocket.OPEN && this.hello && this.connectedAt) {
return {
status: "connected",
connected: true,
protocolVersion: this.hello.protocolVersion,
capabilities: this.hello,
connectedAt: this.connectedAt,
};
}
return {
status: this.httpServer ? "listening" : "stopped",
connected: false,
};
}
async request(
command: string,
args: Record<string, unknown> = {},
requestOptions: UxpRequestOptions = {},
): Promise<unknown> {
const socket = this.socket;
const hello = this.hello;
if (!socket || socket.readyState !== WebSocket.OPEN || !hello) {
throw new UxpBridgeError("UXP_NOT_CONNECTED", "Premiere UXP bridge is not connected");
}
if (hello.commands[command]?.supported !== true) {
throw new UxpBridgeError(
"UXP_COMMAND_UNSUPPORTED",
`Connected Premiere host does not support UXP command '${command}'`,
);
}
const minimumTimeoutMs = requestOptions.minimumTimeoutMs ?? 0;
if (!Number.isInteger(minimumTimeoutMs) || minimumTimeoutMs < 0) {
throw new Error("UXP minimum request timeout must be a non-negative integer");
}
const requestTimeoutMs = Math.max(this.options.requestTimeoutMs, minimumTimeoutMs);
const requestId = randomUUID();
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(requestId);
reject(new UxpBridgeError("UXP_TIMEOUT", `UXP command '${command}' timed out`));
}, requestTimeoutMs);
this.pending.set(requestId, { command, resolve, reject, timer });
socket.send(JSON.stringify({
protocolVersion: hello.protocolVersion,
type: "command",
requestId,
command,
args,
}), (error) => {
if (!error) return;
const pending = this.pending.get(requestId);
if (!pending) return;
clearTimeout(pending.timer);
this.pending.delete(requestId);
pending.reject(new UxpBridgeError("UXP_SEND_FAILED", error.message));
});
});
}
async stop(): Promise<void> {
this.clearConnection(new UxpBridgeError("UXP_STOPPED", "UXP bridge stopped"));
const wsServer = this.wsServer;
const httpServer = this.httpServer;
this.wsServer = null;
this.httpServer = null;
if (wsServer) {
for (const client of wsServer.clients) client.terminate();
wsServer.close();
}
if (httpServer) {
await new Promise<void>((resolve) => httpServer.close(() => resolve()));
}
}
private acceptConnection(client: WebSocket): void {
if (this.socket) {
this.clearConnection(
new UxpBridgeError("UXP_RECONNECTED", "Premiere UXP bridge reconnected"),
);
}
this.socket = client;
this.hello = null;
this.connectedAt = null;
this.handshakeTimer = setTimeout(() => {
client.close(1008, "Versioned hello required");
}, this.options.handshakeTimeoutMs);
client.on("message", (data) => this.handleMessage(client, data.toString()));
client.on("close", () => {
if (client !== this.socket) return;
this.clearConnection(
new UxpBridgeError("UXP_DISCONNECTED", "Premiere UXP bridge disconnected"),
);
this.emit("disconnected");
});
client.on("error", (error) => this.emit("clientError", error));
}
private handleMessage(client: WebSocket, raw: string): void {
let message: any;
try {
message = JSON.parse(raw);
} catch {
client.close(1007, "Invalid JSON");
return;
}
if (!this.hello) {
const hello = message?.type === "hello" ? message.payload : null;
if (
!hello ||
hello.backend !== "uxp" ||
!SUPPORTED_PROTOCOLS.has(message.protocolVersion) ||
hello.protocolVersion !== message.protocolVersion ||
!hello.commands ||
typeof hello.commands !== "object" ||
Array.isArray(hello.commands)
) {
client.close(1008, "Unsupported UXP handshake");
return;
}
if (this.handshakeTimer) clearTimeout(this.handshakeTimer);
this.handshakeTimer = null;
this.hello = hello as UxpHello;
this.connectedAt = new Date().toISOString();
this.emit("connected", this.getState());
return;
}
if (message?.protocolVersion !== this.hello.protocolVersion) {
client.close(1008, "Protocol version changed");
return;
}
if (message?.type === "event") {
this.emit("event", message.payload);
return;
}
if (message?.type !== "result" || typeof message.requestId !== "string") return;
const pending = this.pending.get(message.requestId);
if (!pending) return;
clearTimeout(pending.timer);
this.pending.delete(message.requestId);
if (message.payload?.ok === true) {
pending.resolve(message.payload.result);
} else {
const error = message.payload?.error;
pending.reject(new UxpBridgeError(
error?.code ?? "UXP_COMMAND_FAILED",
error?.message ?? `UXP command '${pending.command}' failed`,
));
}
}
private clearConnection(error: Error): void {
if (this.handshakeTimer) clearTimeout(this.handshakeTimer);
this.handshakeTimer = null;
const socket = this.socket;
this.socket = null;
this.hello = null;
this.connectedAt = null;
if (socket?.readyState === WebSocket.OPEN) socket.close();
for (const pending of this.pending.values()) {
clearTimeout(pending.timer);
pending.reject(error);
}
this.pending.clear();
}
}