PoC 2 & 3: Yoga WASM layout + Kiwi codec
Yoga WASM (yoga-layout v3.2.1): - Vertical/horizontal auto-layout with gap, padding, alignment ✅ - Nested frames with flex-grow ✅ - Space-between distribution ✅ - 1000-node layout in 3ms ✅ Kiwi binary codec (from figma-use multiplayer module): - 194-definition Figma schema (2178 lines) compiles in 6ms - Encode 4 nodes → 237 bytes Zstd-compressed - Full round-trip: encode → Zstd compress → decompress → decode ✅ - Patch kiwi-schema to allow sparse field IDs (Figma uses IDs > 312) Extracted files: schema.ts, codec.ts, protocol.ts, client.ts, index.ts
This commit is contained in:
parent
c123dad50c
commit
ec90f35df8
9
bun.lock
9
bun.lock
|
|
@ -8,8 +8,10 @@
|
|||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"canvaskit-wasm": "^0.40.0",
|
||||
"kiwi-schema": "^0.5.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"yoga-layout": "^3.2.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
|
|
@ -24,6 +26,9 @@
|
|||
},
|
||||
},
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"kiwi-schema@0.5.0": "patches/kiwi-schema@0.5.0.patch",
|
||||
},
|
||||
"packages": {
|
||||
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
|
||||
|
||||
|
|
@ -347,6 +352,8 @@
|
|||
|
||||
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"kiwi-schema": ["kiwi-schema@0.5.0", "", { "bin": { "kiwic": "cli.js" } }, "sha512-X+FpfU0yTEtc6aTHS7VwbOpvQwRt70+pXXWRI5fd6CvWhe7pSVC854TVo4Zo0x5/wwcWj+/9KUlXpdcP0dY9AA=="],
|
||||
|
||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
|
@ -390,5 +397,7 @@
|
|||
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
|
||||
|
||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,8 +17,10 @@
|
|||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"canvaskit-wasm": "^0.40.0",
|
||||
"kiwi-schema": "^0.5.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
"react-dom": "^19.1.0",
|
||||
"yoga-layout": "^3.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
|
|
@ -30,5 +32,8 @@
|
|||
"oxlint": "^1.50.0",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.0.4"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"kiwi-schema@0.5.0": "patches/kiwi-schema@0.5.0.patch"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
13
patches/kiwi-schema@0.5.0.patch
Normal file
13
patches/kiwi-schema@0.5.0.patch
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
diff --git a/kiwi.js b/kiwi.js
|
||||
index 26a46b6a2efbbdb9623a5275751eaaa10378e617..5a57b99fd2fd5fe087fc1c556672c58aa63b57ec 100644
|
||||
--- a/kiwi.js
|
||||
+++ b/kiwi.js
|
||||
@@ -2167,7 +2167,7 @@ function verify(root) {
|
||||
if (field.value <= 0) {
|
||||
error("The id for field " + quote(field.name) + " must be positive", field.line, field.column);
|
||||
}
|
||||
- if (field.value > fields.length) {
|
||||
+ if (false && field.value > fields.length) {
|
||||
error("The id for field " + quote(field.name) + " cannot be larger than " + fields.length, field.line, field.column);
|
||||
}
|
||||
values.push(field.value);
|
||||
199
src/engine/poc-yoga.ts
Normal file
199
src/engine/poc-yoga.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
/**
|
||||
* PoC 2: Yoga WASM Layout Engine
|
||||
*
|
||||
* Validates:
|
||||
* 1. yoga-layout WASM loads and initializes
|
||||
* 2. Flex layout (auto-layout) matches Figma's behavior
|
||||
* 3. Nested frames with padding, gap, alignment
|
||||
* 4. Performance: layout 1000 nodes
|
||||
*/
|
||||
|
||||
import Yoga, {
|
||||
Align,
|
||||
Direction,
|
||||
FlexDirection,
|
||||
Gutter,
|
||||
Justify,
|
||||
Edge,
|
||||
Wrap,
|
||||
type Node as YogaNode
|
||||
} from 'yoga-layout'
|
||||
|
||||
function runTest() {
|
||||
console.log('=== PoC 2: Yoga WASM Layout ===\n')
|
||||
|
||||
// 1. Basic flex layout (Figma auto-layout: vertical, gap=16, padding=24)
|
||||
{
|
||||
const root = Yoga.Node.create()
|
||||
root.setWidth(400)
|
||||
root.setHeight('auto')
|
||||
root.setFlexDirection(FlexDirection.Column)
|
||||
root.setPadding(Edge.All, 24)
|
||||
root.setGap(Gutter.All, 16)
|
||||
|
||||
const child1 = Yoga.Node.create()
|
||||
child1.setWidth('100%')
|
||||
child1.setHeight(48)
|
||||
|
||||
const child2 = Yoga.Node.create()
|
||||
child2.setWidth('100%')
|
||||
child2.setHeight(120)
|
||||
|
||||
const child3 = Yoga.Node.create()
|
||||
child3.setWidth('100%')
|
||||
child3.setHeight(48)
|
||||
|
||||
root.insertChild(child1, 0)
|
||||
root.insertChild(child2, 1)
|
||||
root.insertChild(child3, 2)
|
||||
|
||||
root.calculateLayout(undefined, undefined, Direction.LTR)
|
||||
|
||||
console.log('Test 1: Vertical auto-layout (gap=16, padding=24)')
|
||||
console.log(` Root: ${root.getComputedWidth()}×${root.getComputedHeight()}`)
|
||||
// Expected: height = 24 + 48 + 16 + 120 + 16 + 48 + 24 = 296
|
||||
const expectedHeight = 24 + 48 + 16 + 120 + 16 + 48 + 24
|
||||
const actualHeight = root.getComputedHeight()
|
||||
console.log(
|
||||
` Expected height: ${expectedHeight}, Actual: ${actualHeight} ${actualHeight === expectedHeight ? '✅' : '❌'}`
|
||||
)
|
||||
|
||||
console.log(` Child 1: y=${child1.getComputedTop()}, h=${child1.getComputedHeight()}`)
|
||||
console.log(` Child 2: y=${child2.getComputedTop()}, h=${child2.getComputedHeight()}`)
|
||||
console.log(` Child 3: y=${child3.getComputedTop()}, h=${child3.getComputedHeight()}`)
|
||||
|
||||
// Verify positions
|
||||
const y1 = child1.getComputedTop() // Expected: 24
|
||||
const y2 = child2.getComputedTop() // Expected: 24 + 48 + 16 = 88
|
||||
const y3 = child3.getComputedTop() // Expected: 88 + 120 + 16 = 224
|
||||
console.log(` Positions: ${y1 === 24 && y2 === 88 && y3 === 224 ? '✅' : '❌'}`)
|
||||
|
||||
root.free()
|
||||
child1.free()
|
||||
child2.free()
|
||||
child3.free()
|
||||
}
|
||||
|
||||
// 2. Horizontal auto-layout with space-between (Figma: distribute)
|
||||
{
|
||||
const root = Yoga.Node.create()
|
||||
root.setWidth(600)
|
||||
root.setHeight(80)
|
||||
root.setFlexDirection(FlexDirection.Row)
|
||||
root.setJustifyContent(Justify.SpaceBetween)
|
||||
root.setAlignItems(Align.Center)
|
||||
root.setPadding(Edge.Horizontal, 16)
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const child = Yoga.Node.create()
|
||||
child.setWidth(100)
|
||||
child.setHeight(48)
|
||||
root.insertChild(child, i)
|
||||
}
|
||||
|
||||
root.calculateLayout(undefined, undefined, Direction.LTR)
|
||||
|
||||
console.log('\nTest 2: Horizontal space-between')
|
||||
const positions: string[] = []
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const child = root.getChild(i)
|
||||
positions.push(`x=${child.getComputedLeft()} y=${child.getComputedTop()}`)
|
||||
}
|
||||
console.log(` Children: ${positions.join(' | ')}`)
|
||||
// Items should be centered vertically (y = (80-48)/2 = 16)
|
||||
const centeredCorrectly = root.getChild(0).getComputedTop() === 16
|
||||
console.log(` Vertical centering: ${centeredCorrectly ? '✅' : '❌'}`)
|
||||
// Space-between with padding: available = 600-32 = 568, items = 4*100 = 400, gaps = 168/3 = 56
|
||||
const gap = root.getChild(1).getComputedLeft() - root.getChild(0).getComputedLeft() - 100
|
||||
console.log(` Gap between items: ${gap}px ${Math.abs(gap - 56) < 1 ? '✅' : '❌'}`)
|
||||
|
||||
for (let i = root.getChildCount() - 1; i >= 0; i--) root.getChild(i).free()
|
||||
root.free()
|
||||
}
|
||||
|
||||
// 3. Nested frames (card with header + content)
|
||||
{
|
||||
const card = Yoga.Node.create()
|
||||
card.setWidth(320)
|
||||
card.setFlexDirection(FlexDirection.Column)
|
||||
card.setPadding(Edge.All, 0)
|
||||
|
||||
const header = Yoga.Node.create()
|
||||
header.setWidth('100%')
|
||||
header.setHeight(56)
|
||||
header.setFlexDirection(FlexDirection.Row)
|
||||
header.setAlignItems(Align.Center)
|
||||
header.setPadding(Edge.Horizontal, 16)
|
||||
header.setGap(Gutter.All, 12)
|
||||
|
||||
const avatar = Yoga.Node.create()
|
||||
avatar.setWidth(32)
|
||||
avatar.setHeight(32)
|
||||
|
||||
const title = Yoga.Node.create()
|
||||
title.setFlexGrow(1)
|
||||
title.setHeight(20)
|
||||
|
||||
header.insertChild(avatar, 0)
|
||||
header.insertChild(title, 1)
|
||||
|
||||
const content = Yoga.Node.create()
|
||||
content.setWidth('100%')
|
||||
content.setHeight(200)
|
||||
|
||||
card.insertChild(header, 0)
|
||||
card.insertChild(content, 1)
|
||||
|
||||
card.calculateLayout(undefined, undefined, Direction.LTR)
|
||||
|
||||
console.log('\nTest 3: Nested card layout')
|
||||
console.log(` Card: ${card.getComputedWidth()}×${card.getComputedHeight()}`)
|
||||
console.log(` Header: y=${header.getComputedTop()}, h=${header.getComputedHeight()}`)
|
||||
console.log(
|
||||
` Avatar: x=${avatar.getComputedLeft()}, y=${avatar.getComputedTop()}, centered=${avatar.getComputedTop() === 12 ? '✅' : '❌'}`
|
||||
)
|
||||
console.log(
|
||||
` Title: x=${title.getComputedLeft()}, w=${title.getComputedWidth()}, grows=${title.getComputedWidth() === 320 - 16 - 32 - 12 - 16 ? '✅' : '❌'}`
|
||||
)
|
||||
console.log(` Content: y=${content.getComputedTop()}, h=${content.getComputedHeight()}`)
|
||||
|
||||
avatar.free()
|
||||
title.free()
|
||||
header.free()
|
||||
content.free()
|
||||
card.free()
|
||||
}
|
||||
|
||||
// 4. Performance: layout 1000 nodes
|
||||
{
|
||||
const root = Yoga.Node.create()
|
||||
root.setWidth(1200)
|
||||
root.setFlexDirection(FlexDirection.Row)
|
||||
root.setFlexWrap(Wrap.Wrap) // Wrap
|
||||
root.setGap(Gutter.All, 8)
|
||||
root.setPadding(Edge.All, 16)
|
||||
|
||||
const nodes: YogaNode[] = [root]
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const child = Yoga.Node.create()
|
||||
child.setWidth(80)
|
||||
child.setHeight(80)
|
||||
root.insertChild(child, i)
|
||||
nodes.push(child)
|
||||
}
|
||||
|
||||
const t0 = performance.now()
|
||||
root.calculateLayout(undefined, undefined, Direction.LTR)
|
||||
const layoutTime = performance.now() - t0
|
||||
|
||||
console.log(`\nTest 4: Performance (1000 nodes)`)
|
||||
console.log(` Layout time: ${layoutTime.toFixed(2)}ms ${layoutTime < 50 ? '✅' : '⚠️'}`)
|
||||
console.log(` Root height: ${root.getComputedHeight()}px`)
|
||||
|
||||
for (const n of nodes) n.free()
|
||||
}
|
||||
|
||||
console.log('\n✅ PoC 2 PASSED: Yoga WASM layout works correctly')
|
||||
}
|
||||
|
||||
runTest()
|
||||
349
src/kiwi/client.ts
Normal file
349
src/kiwi/client.ts
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
/**
|
||||
* Figma Multiplayer WebSocket Client
|
||||
*
|
||||
* High-level interface for connecting to Figma's multiplayer server
|
||||
* and creating/modifying nodes directly via WebSocket.
|
||||
*
|
||||
* Performance: ~1000 nodes in 15-20ms (vs 50-100s via plugin API)
|
||||
*/
|
||||
|
||||
import {
|
||||
initCodec,
|
||||
encodeMessage,
|
||||
decodeMessage,
|
||||
decompress,
|
||||
createNodeChangesMessage,
|
||||
type NodeChange,
|
||||
type FigmaMessage
|
||||
} from './codec.ts'
|
||||
import {
|
||||
buildMultiplayerUrl,
|
||||
MESSAGE_TYPES,
|
||||
isZstdCompressed,
|
||||
hasFigWireHeader,
|
||||
skipFigWireHeader,
|
||||
isKiwiMessage,
|
||||
getKiwiMessageType
|
||||
} from './protocol.ts'
|
||||
|
||||
import type { ChromeDevToolsTarget } from '../types.ts'
|
||||
|
||||
export interface SessionInfo {
|
||||
sessionID: number
|
||||
reconnectSequenceNumber: number
|
||||
}
|
||||
|
||||
export interface ConnectionOptions {
|
||||
connectionTimeout?: number
|
||||
onMessage?: (message: FigmaMessage) => void
|
||||
onError?: (error: Error) => void
|
||||
}
|
||||
|
||||
type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'ready'
|
||||
|
||||
export class FigmaMultiplayerClient {
|
||||
private fileKey: string
|
||||
private ws: WebSocket | null = null
|
||||
private state: ConnectionState = 'disconnected'
|
||||
private sessionInfo: SessionInfo | null = null
|
||||
private localIDCounter: number
|
||||
private options: ConnectionOptions
|
||||
|
||||
constructor(fileKey: string, options: ConnectionOptions = {}) {
|
||||
this.fileKey = fileKey
|
||||
this.localIDCounter = Date.now() % 10000000
|
||||
this.options = {
|
||||
connectionTimeout: 30000,
|
||||
...options
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to Figma multiplayer server
|
||||
*/
|
||||
async connect(cookies: string): Promise<SessionInfo> {
|
||||
if (this.state !== 'disconnected') {
|
||||
throw new Error(`Cannot connect: state is ${this.state}`)
|
||||
}
|
||||
|
||||
await initCodec()
|
||||
this.state = 'connecting'
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = buildMultiplayerUrl(this.fileKey)
|
||||
|
||||
this.ws = new WebSocket(url, {
|
||||
headers: {
|
||||
Cookie: cookies,
|
||||
Origin: 'https://www.figma.com'
|
||||
}
|
||||
} as WebSocketInit)
|
||||
|
||||
this.ws.binaryType = 'arraybuffer'
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
this.close()
|
||||
reject(new Error('Connection timeout'))
|
||||
}, this.options.connectionTimeout)
|
||||
|
||||
let sessionID = 0
|
||||
let reconnectSequenceNumber = 0
|
||||
let joinEndReceived = false
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
if (!(event.data instanceof ArrayBuffer)) return
|
||||
|
||||
let data: Uint8Array = new Uint8Array(event.data as ArrayBuffer)
|
||||
|
||||
// Skip fig-wire header if present
|
||||
if (hasFigWireHeader(data)) {
|
||||
data = new Uint8Array(skipFigWireHeader(data))
|
||||
}
|
||||
|
||||
if (!isZstdCompressed(data)) return
|
||||
|
||||
try {
|
||||
const decompressed = decompress(data)
|
||||
|
||||
// Skip non-Kiwi message data (e.g., schema definitions)
|
||||
if (!isKiwiMessage(decompressed)) return
|
||||
|
||||
const msgType = getKiwiMessageType(decompressed)!
|
||||
|
||||
// JOIN_END: sync complete
|
||||
if (msgType === MESSAGE_TYPES.JOIN_END) {
|
||||
joinEndReceived = true
|
||||
}
|
||||
|
||||
// SIGNAL: extract reconnect-sequence-number
|
||||
if (msgType === MESSAGE_TYPES.SIGNAL) {
|
||||
const str = new TextDecoder().decode(decompressed)
|
||||
const match = str.match(/reconnect-sequence-number[^\d]*(\d+)/)
|
||||
if (match?.[1]) {
|
||||
reconnectSequenceNumber = parseInt(match[1])
|
||||
}
|
||||
}
|
||||
|
||||
// Forward messages when ready
|
||||
if (this.options.onMessage && this.state === 'ready') {
|
||||
try {
|
||||
const message = decodeMessage(data)
|
||||
this.options.onMessage(message)
|
||||
} catch {
|
||||
// Ignore decode errors
|
||||
}
|
||||
}
|
||||
|
||||
// Check if handshake complete
|
||||
// Note: sessionID may be 0 if Figma changed protocol; caller gets it from plugin API
|
||||
if (joinEndReceived && (this.state === 'connecting' || this.state === 'connected')) {
|
||||
clearTimeout(timeout)
|
||||
this.state = 'ready'
|
||||
this.sessionInfo = { sessionID: sessionID || 0, reconnectSequenceNumber }
|
||||
resolve(this.sessionInfo)
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.state = 'connected'
|
||||
}
|
||||
|
||||
this.ws.onerror = () => {
|
||||
clearTimeout(timeout)
|
||||
const error = new Error('WebSocket connection failed')
|
||||
this.options.onError?.(error)
|
||||
if (this.state === 'connecting') {
|
||||
reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
this.ws.onclose = () => {
|
||||
clearTimeout(timeout)
|
||||
const wasConnecting = this.state === 'connecting'
|
||||
this.state = 'disconnected'
|
||||
this.ws = null
|
||||
if (wasConnecting) {
|
||||
reject(new Error('Connection closed during handshake'))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Send node changes to Figma (fire and forget)
|
||||
*/
|
||||
async sendNodeChanges(nodeChanges: NodeChange[]): Promise<void> {
|
||||
if (this.state !== 'ready' || !this.ws || !this.sessionInfo) {
|
||||
throw new Error('Not connected')
|
||||
}
|
||||
|
||||
const message = createNodeChangesMessage(
|
||||
this.sessionInfo.sessionID,
|
||||
this.sessionInfo.reconnectSequenceNumber,
|
||||
nodeChanges
|
||||
)
|
||||
|
||||
const encoded = encodeMessage(message)
|
||||
this.ws.send(encoded)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send node changes and wait for server ACK (NODE_CHANGES echo)
|
||||
* This guarantees nodes are synced before returning
|
||||
*/
|
||||
async sendNodeChangesSync(nodeChanges: NodeChange[], timeout = 5000): Promise<void> {
|
||||
if (this.state !== 'ready' || !this.ws || !this.sessionInfo) {
|
||||
throw new Error('Not connected')
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.ws?.removeEventListener('message', handler)
|
||||
reject(new Error('ACK timeout'))
|
||||
}, timeout)
|
||||
|
||||
const handler = (event: MessageEvent) => {
|
||||
if (!(event.data instanceof ArrayBuffer)) return
|
||||
|
||||
let data: Uint8Array = new Uint8Array(event.data as ArrayBuffer)
|
||||
if (hasFigWireHeader(data)) data = new Uint8Array(skipFigWireHeader(data))
|
||||
if (!isZstdCompressed(data)) return
|
||||
|
||||
try {
|
||||
const dec = decompress(data)
|
||||
if (isKiwiMessage(dec) && getKiwiMessageType(dec) === MESSAGE_TYPES.NODE_CHANGES) {
|
||||
clearTimeout(timer)
|
||||
this.ws?.removeEventListener('message', handler)
|
||||
resolve()
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
this.ws!.addEventListener('message', handler)
|
||||
this.sendNodeChanges(nodeChanges)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique local ID for new nodes
|
||||
*/
|
||||
nextLocalID(): number {
|
||||
return this.localIDCounter++
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current session info
|
||||
*/
|
||||
getSession(): SessionInfo | null {
|
||||
return this.sessionInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if connected and ready
|
||||
*/
|
||||
isReady(): boolean {
|
||||
return this.state === 'ready'
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if connection is still alive
|
||||
*/
|
||||
isConnected(): boolean {
|
||||
return this.state === 'connected' && this.ws !== null && this.ws.readyState === WebSocket.OPEN
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the connection
|
||||
*/
|
||||
close(): void {
|
||||
if (this.ws) {
|
||||
this.ws.close()
|
||||
this.ws = null
|
||||
}
|
||||
this.state = 'disconnected'
|
||||
this.sessionInfo = null
|
||||
}
|
||||
}
|
||||
|
||||
// WebSocket with headers support (Bun/Node specific)
|
||||
interface WebSocketInit {
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Figma cookies from Chrome DevTools Protocol
|
||||
*
|
||||
* Requires Chrome/Figma to be running with:
|
||||
* --remote-debugging-port=9222
|
||||
*/
|
||||
export async function getCookiesFromDevTools(pageId?: string): Promise<string> {
|
||||
// Get list of pages if no ID provided
|
||||
if (!pageId) {
|
||||
const listResponse = await fetch('http://localhost:9222/json')
|
||||
const targets = (await listResponse.json()) as ChromeDevToolsTarget[]
|
||||
const figmaTarget = targets.find((t) => t.url.includes('figma.com'))
|
||||
if (!figmaTarget) {
|
||||
throw new Error('No Figma tab found. Open Figma in Chrome with --remote-debugging-port=9222')
|
||||
}
|
||||
pageId = figmaTarget.id
|
||||
}
|
||||
|
||||
const wsUrl = `ws://localhost:9222/devtools/page/${pageId}`
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(wsUrl)
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
ws.close()
|
||||
reject(new Error('DevTools connection timeout'))
|
||||
}, 5000)
|
||||
|
||||
ws.onopen = () => {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
id: 1,
|
||||
method: 'Network.getCookies',
|
||||
params: { urls: ['https://www.figma.com'] }
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
clearTimeout(timeout)
|
||||
const data = JSON.parse(e.data as string)
|
||||
if (data.result?.cookies) {
|
||||
ws.close()
|
||||
const cookieString = data.result.cookies
|
||||
.map((c: { name: string; value: string }) => `${c.name}=${c.value}`)
|
||||
.join('; ')
|
||||
resolve(cookieString)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
clearTimeout(timeout)
|
||||
reject(
|
||||
new Error(
|
||||
'Cannot connect to Chrome DevTools. Is Chrome running with --remote-debugging-port=9222?'
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Figma file key from URL
|
||||
*/
|
||||
export function parseFileKey(urlOrKey: string): string {
|
||||
if (!urlOrKey.includes('/')) {
|
||||
return urlOrKey
|
||||
}
|
||||
const match = urlOrKey.match(/figma\.com\/(?:file|design)\/([a-zA-Z0-9]+)/)
|
||||
if (!match?.[1]) {
|
||||
throw new Error('Invalid Figma URL')
|
||||
}
|
||||
return match[1]
|
||||
}
|
||||
559
src/kiwi/codec.ts
Normal file
559
src/kiwi/codec.ts
Normal file
|
|
@ -0,0 +1,559 @@
|
|||
/**
|
||||
* Message Encoding/Decoding for Figma Multiplayer
|
||||
*
|
||||
* Uses:
|
||||
* - kiwi-schema: Binary serialization (by Evan Wallace, Figma co-founder)
|
||||
* - Bun.zstd*: Native Zstd compression (built into Bun)
|
||||
*/
|
||||
|
||||
import { compileSchema, type Schema } from 'kiwi-schema'
|
||||
|
||||
import { isZstdCompressed, getKiwiMessageType } from './protocol.ts'
|
||||
import figmaSchema from './schema.ts'
|
||||
|
||||
function parseColor(color: string): Color {
|
||||
if (color.startsWith('#')) {
|
||||
const hex = color.slice(1)
|
||||
return {
|
||||
r: parseInt(hex.slice(0, 2), 16) / 255,
|
||||
g: parseInt(hex.slice(2, 4), 16) / 255,
|
||||
b: parseInt(hex.slice(4, 6), 16) / 255,
|
||||
a: hex.length > 6 ? parseInt(hex.slice(6, 8), 16) / 255 : 1
|
||||
}
|
||||
}
|
||||
return { r: 0, g: 0, b: 0, a: 1 }
|
||||
}
|
||||
|
||||
interface CompiledSchema {
|
||||
encodeMessage(message: unknown): Uint8Array
|
||||
decodeMessage(data: Uint8Array): unknown
|
||||
}
|
||||
|
||||
let compiledSchema: CompiledSchema | null = null
|
||||
|
||||
/**
|
||||
* Initialize the codec (compiles Kiwi schema)
|
||||
*/
|
||||
export async function initCodec(): Promise<void> {
|
||||
if (compiledSchema) return
|
||||
compiledSchema = compileSchema(figmaSchema as Schema) as CompiledSchema
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if codec is initialized
|
||||
*/
|
||||
export function isCodecReady(): boolean {
|
||||
return compiledSchema !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Compress data using Zstd (Bun native)
|
||||
*/
|
||||
export function compress(data: Uint8Array): Uint8Array {
|
||||
return Bun.zstdCompressSync(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decompress Zstd data (Bun native)
|
||||
*/
|
||||
export function decompress(data: Uint8Array): Uint8Array {
|
||||
if (!isZstdCompressed(data)) return data
|
||||
return Bun.zstdDecompressSync(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a message for sending to Figma
|
||||
* Handles variable bindings in fillPaints which require custom encoding
|
||||
*/
|
||||
export function encodeMessage(message: FigmaMessage): Uint8Array {
|
||||
if (!compiledSchema) {
|
||||
throw new Error('Codec not initialized. Call initCodec() first.')
|
||||
}
|
||||
|
||||
// Check if any nodeChange has variable bindings (fill or stroke)
|
||||
const hasVariables = message.nodeChanges?.some(
|
||||
(nc) =>
|
||||
nc.fillPaints?.some((p) => p.colorVariableBinding) ||
|
||||
nc.strokePaints?.some((p) => p.colorVariableBinding)
|
||||
)
|
||||
|
||||
if (!hasVariables) {
|
||||
// Standard encoding
|
||||
const encoded = compiledSchema.encodeMessage(message)
|
||||
return compress(encoded)
|
||||
}
|
||||
|
||||
// Need custom encoding for variable bindings
|
||||
// Strategy: encode each nodeChange separately, then combine
|
||||
const messageWithoutNodes = { ...message, nodeChanges: [] }
|
||||
const baseEncoded = compiledSchema.encodeMessage(messageWithoutNodes)
|
||||
const baseHex = Buffer.from(baseEncoded).toString('hex')
|
||||
|
||||
// Encode nodeChanges with variable support
|
||||
const nodeChangeBytes: Uint8Array[] = []
|
||||
for (const nc of message.nodeChanges || []) {
|
||||
const encoded = encodeNodeChangeWithVariables(nc)
|
||||
nodeChangeBytes.push(encoded)
|
||||
}
|
||||
|
||||
// Combine: base message + nodeChanges
|
||||
// Message structure: type, sessionID, ackID, reconnectSeqNum, nodeChanges[]
|
||||
// nodeChanges is field 5
|
||||
|
||||
// Message structure in kiwi:
|
||||
// - Field 1 (type): enum MessageType
|
||||
// - Field 2 (sessionID): uint
|
||||
// - Field 3 (ackID): uint
|
||||
// - Field 4 (nodeChanges): NodeChange[] - this is what we need to replace
|
||||
// - Field 25 (reconnectSequenceNumber): uint
|
||||
//
|
||||
// Empty array: "04 00" (field 4, length 0)
|
||||
// We need to replace "04 00" with "04 <count> <nodes>"
|
||||
|
||||
const emptyArrayPattern = '0400' // field 4, length 0
|
||||
const emptyArrayIdx = baseHex.indexOf(emptyArrayPattern)
|
||||
|
||||
if (emptyArrayIdx === -1) {
|
||||
// Fallback to standard encoding
|
||||
const encoded = compiledSchema.encodeMessage(message)
|
||||
return compress(encoded)
|
||||
}
|
||||
|
||||
// Build nodeChanges array with our encoded nodes
|
||||
const ncBytes: number[] = [0x04] // field 4
|
||||
ncBytes.push(...encodeVarint(nodeChangeBytes.length)) // array length
|
||||
for (const ncArr of nodeChangeBytes) {
|
||||
ncBytes.push(...Array.from(ncArr))
|
||||
}
|
||||
|
||||
// Replace "0400" with our nodeChanges
|
||||
const beforeArray = baseHex.slice(0, emptyArrayIdx)
|
||||
const afterArray = baseHex.slice(emptyArrayIdx + 4) // skip "0400"
|
||||
|
||||
const ncHex = Buffer.from(ncBytes).toString('hex')
|
||||
const finalHex = beforeArray + ncHex + afterArray
|
||||
|
||||
const finalBytes = new Uint8Array(finalHex.match(/.{2}/g)!.map((b) => parseInt(b, 16)))
|
||||
return compress(finalBytes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a message received from Figma
|
||||
*/
|
||||
export function decodeMessage(data: Uint8Array): FigmaMessage {
|
||||
if (!compiledSchema) {
|
||||
throw new Error('Codec not initialized. Call initCodec() first.')
|
||||
}
|
||||
|
||||
const decompressed = decompress(data)
|
||||
return compiledSchema.decodeMessage(decompressed) as FigmaMessage
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick peek at message type without full decoding
|
||||
*/
|
||||
export function peekMessageType(data: Uint8Array): number | null {
|
||||
try {
|
||||
const decompressed = decompress(data)
|
||||
return getKiwiMessageType(decompressed)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Type definitions
|
||||
|
||||
export interface GUID {
|
||||
sessionID: number
|
||||
localID: number
|
||||
}
|
||||
|
||||
export interface Color {
|
||||
r: number
|
||||
g: number
|
||||
b: number
|
||||
a: number
|
||||
}
|
||||
|
||||
export interface Vector {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface Matrix {
|
||||
m00: number
|
||||
m01: number
|
||||
m02: number
|
||||
m10: number
|
||||
m11: number
|
||||
m12: number
|
||||
}
|
||||
|
||||
export interface ParentIndex {
|
||||
guid: GUID
|
||||
position: string
|
||||
}
|
||||
|
||||
export interface VariableBinding {
|
||||
variableID: GUID
|
||||
}
|
||||
|
||||
export interface Paint {
|
||||
type: 'SOLID' | 'GRADIENT_LINEAR' | 'GRADIENT_RADIAL' | 'IMAGE'
|
||||
color?: Color
|
||||
opacity?: number
|
||||
visible?: boolean
|
||||
blendMode?: string
|
||||
colorVariableBinding?: VariableBinding // Binds color to a Figma variable
|
||||
}
|
||||
|
||||
export interface Effect {
|
||||
type: 'DROP_SHADOW' | 'INNER_SHADOW' | 'BACKGROUND_BLUR' | 'FOREGROUND_BLUR'
|
||||
color?: Color
|
||||
offset?: Vector
|
||||
radius?: number
|
||||
visible?: boolean
|
||||
spread?: number
|
||||
}
|
||||
|
||||
export interface NodeChange {
|
||||
guid: GUID
|
||||
phase?: 'CREATED' | 'REMOVED'
|
||||
parentIndex?: ParentIndex
|
||||
type?: string
|
||||
name?: string
|
||||
visible?: boolean
|
||||
locked?: boolean
|
||||
opacity?: number
|
||||
blendMode?: string
|
||||
size?: Vector
|
||||
transform?: Matrix
|
||||
cornerRadius?: number
|
||||
fillPaints?: Paint[]
|
||||
strokePaints?: Paint[]
|
||||
strokeWeight?: number
|
||||
strokeAlign?: string
|
||||
effects?: Effect[]
|
||||
// Layout
|
||||
stackMode?: 'NONE' | 'HORIZONTAL' | 'VERTICAL'
|
||||
stackSpacing?: number
|
||||
stackPadding?: number
|
||||
stackPaddingRight?: number
|
||||
stackPaddingBottom?: number
|
||||
stackCounterAlign?: string
|
||||
stackJustify?: string
|
||||
stackCounterAlignItems?: string
|
||||
stackPrimaryAlignItems?: string
|
||||
stackPrimarySizing?: 'FIXED' | 'RESIZE_TO_FIT'
|
||||
stackCounterSizing?: 'FIXED' | 'RESIZE_TO_FIT'
|
||||
stackVerticalPadding?: number
|
||||
stackHorizontalPadding?: number
|
||||
// Frame
|
||||
clipsContent?: boolean
|
||||
// Text
|
||||
fontSize?: number
|
||||
fontName?: { family: string; style: string; postscript?: string }
|
||||
textAlignHorizontal?: string
|
||||
textAlignVertical?: string
|
||||
textAutoResize?: string
|
||||
textData?: { characters: string }
|
||||
lineHeight?: { value: number; units: string }
|
||||
letterSpacing?: { value: number; units: string }
|
||||
// Symbol/Instance
|
||||
symbolData?: { symbolID: { sessionID: number; localID: number } }
|
||||
// ComponentSet
|
||||
isStateGroup?: boolean
|
||||
stateGroupPropertyValueOrders?: Array<{ property: string; values: string[] }>
|
||||
// Corners
|
||||
rectangleTopLeftCornerRadius?: number
|
||||
rectangleTopRightCornerRadius?: number
|
||||
rectangleBottomLeftCornerRadius?: number
|
||||
rectangleBottomRightCornerRadius?: number
|
||||
rectangleCornerRadiiIndependent?: boolean
|
||||
cornerSmoothing?: number
|
||||
// Constraints
|
||||
horizontalConstraint?: string
|
||||
verticalConstraint?: string
|
||||
}
|
||||
|
||||
export interface FigmaMessage {
|
||||
type: string
|
||||
sessionID?: number
|
||||
ackID?: number
|
||||
reconnectSequenceNumber?: number
|
||||
nodeChanges?: NodeChange[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a NODE_CHANGES message
|
||||
*/
|
||||
export function createNodeChangesMessage(
|
||||
sessionID: number,
|
||||
reconnectSequenceNumber: number,
|
||||
nodeChanges: NodeChange[],
|
||||
ackID = 1
|
||||
): FigmaMessage {
|
||||
return {
|
||||
type: 'NODE_CHANGES',
|
||||
sessionID,
|
||||
ackID,
|
||||
reconnectSequenceNumber,
|
||||
nodeChanges
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a node change for a new shape
|
||||
*/
|
||||
export function createNodeChange(opts: {
|
||||
sessionID: number
|
||||
localID: number
|
||||
parentSessionID: number
|
||||
parentLocalID: number
|
||||
position?: string
|
||||
type: string
|
||||
name: string
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
fill?: Color | string
|
||||
stroke?: Color | string
|
||||
strokeWeight?: number
|
||||
cornerRadius?: number
|
||||
opacity?: number
|
||||
}): NodeChange {
|
||||
const change: NodeChange = {
|
||||
guid: { sessionID: opts.sessionID, localID: opts.localID },
|
||||
phase: 'CREATED',
|
||||
parentIndex: {
|
||||
guid: { sessionID: opts.parentSessionID, localID: opts.parentLocalID },
|
||||
position: opts.position || '!'
|
||||
},
|
||||
type: opts.type,
|
||||
name: opts.name,
|
||||
visible: true,
|
||||
opacity: opts.opacity ?? 1.0,
|
||||
size: { x: opts.width, y: opts.height },
|
||||
transform: {
|
||||
m00: 1,
|
||||
m01: 0,
|
||||
m02: opts.x,
|
||||
m10: 0,
|
||||
m11: 1,
|
||||
m12: opts.y
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.fill) {
|
||||
const color = typeof opts.fill === 'string' ? parseColor(opts.fill) : opts.fill
|
||||
change.fillPaints = [
|
||||
{
|
||||
type: 'SOLID',
|
||||
color,
|
||||
opacity: 1.0,
|
||||
visible: true,
|
||||
blendMode: 'NORMAL'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
if (opts.stroke) {
|
||||
const color = typeof opts.stroke === 'string' ? parseColor(opts.stroke) : opts.stroke
|
||||
change.strokePaints = [
|
||||
{
|
||||
type: 'SOLID',
|
||||
color,
|
||||
opacity: 1.0,
|
||||
visible: true,
|
||||
blendMode: 'NORMAL'
|
||||
}
|
||||
]
|
||||
change.strokeWeight = opts.strokeWeight ?? 1
|
||||
}
|
||||
|
||||
if (opts.cornerRadius !== undefined) {
|
||||
change.cornerRadius = opts.cornerRadius
|
||||
}
|
||||
|
||||
return change
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a varint (variable-length integer)
|
||||
*/
|
||||
function encodeVarint(value: number): number[] {
|
||||
const bytes: number[] = []
|
||||
while (value > 0x7f) {
|
||||
bytes.push((value & 0x7f) | 0x80)
|
||||
value >>>= 7
|
||||
}
|
||||
bytes.push(value)
|
||||
return bytes
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a Paint with optional variable binding
|
||||
*
|
||||
* Figma's variable binding format (discovered via WS traffic analysis):
|
||||
* - Field 21 = 1 (binding type)
|
||||
* - Field 4 = 1 (flag)
|
||||
* - Raw sessionID varint (no field number)
|
||||
* - Raw localID varint (no field number)
|
||||
* - Terminators: 00 00 02 03 03 04 00 00
|
||||
*/
|
||||
export function encodePaintWithVariableBinding(
|
||||
paint: Paint,
|
||||
variableSessionID: number,
|
||||
variableLocalID: number
|
||||
): Uint8Array {
|
||||
if (!compiledSchema) {
|
||||
throw new Error('Codec not initialized. Call initCodec() first.')
|
||||
}
|
||||
|
||||
// Encode base paint without variable binding
|
||||
const basePaint = { ...paint }
|
||||
delete (basePaint as any).colorVariableBinding
|
||||
|
||||
const baseBytes = (compiledSchema as any).encodePaint(basePaint)
|
||||
const baseArray = Array.from(baseBytes) as number[]
|
||||
|
||||
// Remove trailing 00
|
||||
if (baseArray[baseArray.length - 1] === 0) {
|
||||
baseArray.pop()
|
||||
}
|
||||
|
||||
// Add variable binding in Figma's exact format:
|
||||
// Field 21 (0x15) = 1 (binding type)
|
||||
baseArray.push(0x15, 0x01)
|
||||
// Field 4 = 1 (flag)
|
||||
baseArray.push(0x04, 0x01)
|
||||
// Raw varints: sessionID, localID (no field numbers!)
|
||||
baseArray.push(...encodeVarint(variableSessionID))
|
||||
baseArray.push(...encodeVarint(variableLocalID))
|
||||
// Terminators observed in Figma traffic
|
||||
baseArray.push(0x00, 0x00, 0x02, 0x03, 0x03, 0x04)
|
||||
// Final terminators
|
||||
baseArray.push(0x00, 0x00)
|
||||
|
||||
return new Uint8Array(baseArray)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a variable ID string (e.g., "VariableID:38448:122296")
|
||||
* Returns sessionID and localID
|
||||
*/
|
||||
export function parseVariableId(variableId: string): { sessionID: number; localID: number } | null {
|
||||
const match = variableId.match(/VariableID:(\d+):(\d+)/)
|
||||
if (!match) return null
|
||||
return {
|
||||
sessionID: parseInt(match[1]!, 10),
|
||||
localID: parseInt(match[2]!, 10)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a NodeChange with variable bindings in fillPaints and/or strokePaints
|
||||
* This is needed because kiwi-schema cannot produce Figma's exact variable binding format
|
||||
*/
|
||||
export function encodeNodeChangeWithVariables(nodeChange: NodeChange): Uint8Array {
|
||||
if (!compiledSchema) {
|
||||
throw new Error('Codec not initialized. Call initCodec() first.')
|
||||
}
|
||||
|
||||
const hasFillBinding = nodeChange.fillPaints?.some((p) => p.colorVariableBinding)
|
||||
const hasStrokeBinding = nodeChange.strokePaints?.some((p) => p.colorVariableBinding)
|
||||
|
||||
if (!hasFillBinding && !hasStrokeBinding) {
|
||||
return (compiledSchema as any).encodeNodeChange(nodeChange)
|
||||
}
|
||||
|
||||
// Create a copy without variable bindings for base encoding
|
||||
const cleanNodeChange = { ...nodeChange }
|
||||
if (cleanNodeChange.fillPaints) {
|
||||
cleanNodeChange.fillPaints = cleanNodeChange.fillPaints.map((p) => {
|
||||
const clean = { ...p }
|
||||
delete (clean as any).colorVariableBinding
|
||||
return clean
|
||||
})
|
||||
}
|
||||
if (cleanNodeChange.strokePaints) {
|
||||
cleanNodeChange.strokePaints = cleanNodeChange.strokePaints.map((p) => {
|
||||
const clean = { ...p }
|
||||
delete (clean as any).colorVariableBinding
|
||||
return clean
|
||||
})
|
||||
}
|
||||
|
||||
// Encode clean version
|
||||
const baseBytes = (compiledSchema as any).encodeNodeChange(cleanNodeChange)
|
||||
let hex = Buffer.from(baseBytes).toString('hex')
|
||||
|
||||
// Inject fill variable binding (field 38 = 0x26)
|
||||
const fillBinding = nodeChange.fillPaints?.[0]?.colorVariableBinding
|
||||
if (hasFillBinding && fillBinding) {
|
||||
hex = injectVariableBinding(hex, '2601', fillBinding)
|
||||
}
|
||||
|
||||
// Inject stroke variable binding (field 39 = 0x27)
|
||||
const strokeBinding = nodeChange.strokePaints?.[0]?.colorVariableBinding
|
||||
if (hasStrokeBinding && strokeBinding) {
|
||||
hex = injectVariableBinding(hex, '2701', strokeBinding)
|
||||
}
|
||||
|
||||
return new Uint8Array(hex.match(/.{2}/g)!.map((b) => parseInt(b, 16)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject variable binding into a paint at the specified marker
|
||||
*/
|
||||
function injectVariableBinding(
|
||||
hex: string,
|
||||
marker: string,
|
||||
binding: { variableID: { sessionID: number; localID: number } }
|
||||
): string {
|
||||
const markerIdx = hex.indexOf(marker)
|
||||
if (markerIdx === -1) return hex
|
||||
|
||||
// Find visible=true pattern (04 01) after the marker
|
||||
const visiblePattern = '0401'
|
||||
let patternIdx = hex.indexOf(visiblePattern, markerIdx)
|
||||
if (patternIdx === -1) return hex
|
||||
|
||||
// Move past 0401 to find where to insert
|
||||
let insertPoint = patternIdx + visiblePattern.length
|
||||
|
||||
// Check if blendMode follows (05 01)
|
||||
if (hex.slice(insertPoint, insertPoint + 4) === '0501') {
|
||||
insertPoint += 4
|
||||
}
|
||||
|
||||
// Build variable binding bytes
|
||||
const varBytes = [
|
||||
0x15,
|
||||
0x01, // Field 21 (variableBinding) = 1
|
||||
0x04,
|
||||
0x01, // Nested field 4 = 1
|
||||
...encodeVarint(binding.variableID.sessionID),
|
||||
...encodeVarint(binding.variableID.localID),
|
||||
0x00,
|
||||
0x00,
|
||||
0x02,
|
||||
0x03,
|
||||
0x03,
|
||||
0x04,
|
||||
0x00,
|
||||
0x00 // Terminators
|
||||
]
|
||||
const varHex = Buffer.from(varBytes).toString('hex')
|
||||
|
||||
const beforeVar = hex.slice(0, insertPoint)
|
||||
// Skip original paint terminator (00) - our varHex includes terminators
|
||||
let afterIdx = insertPoint
|
||||
if (hex.slice(afterIdx, afterIdx + 2) === '00') {
|
||||
afterIdx += 2
|
||||
}
|
||||
const afterVar = hex.slice(afterIdx)
|
||||
|
||||
return beforeVar + varHex + afterVar
|
||||
}
|
||||
70
src/kiwi/index.ts
Normal file
70
src/kiwi/index.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
/**
|
||||
* Figma Multiplayer Module
|
||||
*
|
||||
* Direct WebSocket access to Figma's multiplayer protocol.
|
||||
* Enables creating nodes 1000-5000x faster than the plugin API.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { FigmaMultiplayerClient, getCookiesFromDevTools } from './multiplayer'
|
||||
*
|
||||
* const cookies = await getCookiesFromDevTools()
|
||||
* const client = new FigmaMultiplayerClient('fileKey')
|
||||
* await client.connect(cookies)
|
||||
*
|
||||
* await client.createNodes([{
|
||||
* guid: { sessionID: client.getSessionInfo()!.sessionID, localID: 1 },
|
||||
* phase: 'CREATED',
|
||||
* type: 'RECTANGLE',
|
||||
* name: 'My Rect',
|
||||
* // ...
|
||||
* }])
|
||||
*
|
||||
* client.close()
|
||||
* ```
|
||||
*/
|
||||
|
||||
export {
|
||||
FigmaMultiplayerClient,
|
||||
getCookiesFromDevTools,
|
||||
parseFileKey,
|
||||
type SessionInfo,
|
||||
type ConnectionOptions
|
||||
} from './client.ts'
|
||||
|
||||
export {
|
||||
initCodec,
|
||||
encodeMessage,
|
||||
decodeMessage,
|
||||
createNodeChangesMessage,
|
||||
createNodeChange,
|
||||
parseVariableId,
|
||||
encodePaintWithVariableBinding,
|
||||
encodeNodeChangeWithVariables,
|
||||
type GUID,
|
||||
type Color,
|
||||
type Paint,
|
||||
type VariableBinding,
|
||||
type ParentIndex,
|
||||
type NodeChange,
|
||||
type FigmaMessage
|
||||
} from './codec.ts'
|
||||
|
||||
export {
|
||||
MESSAGE_TYPES,
|
||||
NODE_TYPES,
|
||||
NODE_PHASES,
|
||||
BLEND_MODES,
|
||||
PAINT_TYPES,
|
||||
PROTOCOL_VERSION,
|
||||
KIWI,
|
||||
SESSION_ID,
|
||||
ZSTD_MAGIC,
|
||||
buildMultiplayerUrl,
|
||||
isZstdCompressed,
|
||||
hasFigWireHeader,
|
||||
skipFigWireHeader,
|
||||
isKiwiMessage,
|
||||
getKiwiMessageType,
|
||||
parseVarint
|
||||
} from './protocol.ts'
|
||||
180
src/kiwi/poc-test.ts
Normal file
180
src/kiwi/poc-test.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
/**
|
||||
* PoC 3: Kiwi codec round-trip test
|
||||
*
|
||||
* Validates:
|
||||
* 1. Schema compiles (194 definitions, 2178 lines)
|
||||
* 2. Encode a NODE_CHANGES message with shapes
|
||||
* 3. Decode it back — verify data integrity
|
||||
* 4. Zstd compress/decompress round-trip
|
||||
*/
|
||||
|
||||
import {
|
||||
initCodec,
|
||||
encodeMessage,
|
||||
decodeMessage,
|
||||
createNodeChange,
|
||||
createNodeChangesMessage
|
||||
} from './codec.ts'
|
||||
|
||||
import type { NodeChange } from './codec.ts'
|
||||
|
||||
async function runTest() {
|
||||
console.log('=== PoC 3: Kiwi Codec Round-Trip ===\n')
|
||||
|
||||
// 1. Init codec (compiles schema)
|
||||
const t0 = performance.now()
|
||||
await initCodec()
|
||||
console.log(`✅ Schema compiled in ${(performance.now() - t0).toFixed(1)}ms`)
|
||||
|
||||
// 2. Create test node changes
|
||||
const sessionID = 99999
|
||||
const changes: NodeChange[] = [
|
||||
createNodeChange({
|
||||
sessionID,
|
||||
localID: 1,
|
||||
parentSessionID: 0,
|
||||
parentLocalID: 0,
|
||||
type: 'FRAME',
|
||||
name: 'Test Frame',
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 800,
|
||||
height: 600,
|
||||
fill: '#FFFFFF'
|
||||
}),
|
||||
createNodeChange({
|
||||
sessionID,
|
||||
localID: 2,
|
||||
parentSessionID: sessionID,
|
||||
parentLocalID: 1,
|
||||
type: 'RECTANGLE',
|
||||
name: 'Blue Card',
|
||||
x: 50,
|
||||
y: 50,
|
||||
width: 200,
|
||||
height: 150,
|
||||
fill: '#3B82F6',
|
||||
cornerRadius: 12
|
||||
}),
|
||||
createNodeChange({
|
||||
sessionID,
|
||||
localID: 3,
|
||||
parentSessionID: sessionID,
|
||||
parentLocalID: 1,
|
||||
type: 'ELLIPSE',
|
||||
name: 'Green Circle',
|
||||
x: 300,
|
||||
y: 80,
|
||||
width: 100,
|
||||
height: 100,
|
||||
fill: '#22C55E'
|
||||
}),
|
||||
createNodeChange({
|
||||
sessionID,
|
||||
localID: 4,
|
||||
parentSessionID: sessionID,
|
||||
parentLocalID: 1,
|
||||
type: 'TEXT',
|
||||
name: 'Hello World',
|
||||
x: 50,
|
||||
y: 250,
|
||||
width: 200,
|
||||
height: 40,
|
||||
fill: '#000000'
|
||||
})
|
||||
]
|
||||
|
||||
console.log(`✅ Created ${changes.length} node changes`)
|
||||
|
||||
// 3. Encode
|
||||
const message = createNodeChangesMessage(sessionID, 1, changes)
|
||||
const t1 = performance.now()
|
||||
const encoded = encodeMessage(message)
|
||||
const encodeTime = performance.now() - t1
|
||||
console.log(`✅ Encoded: ${encoded.byteLength} bytes (${encodeTime.toFixed(2)}ms)`)
|
||||
console.log(
|
||||
` Zstd compressed: starts with 0x${encoded[0]?.toString(16)}${encoded[1]?.toString(16)}${encoded[2]?.toString(16)}${encoded[3]?.toString(16)}`
|
||||
)
|
||||
|
||||
// 4. Decode
|
||||
const t2 = performance.now()
|
||||
const decoded = decodeMessage(encoded)
|
||||
const decodeTime = performance.now() - t2
|
||||
console.log(`✅ Decoded in ${decodeTime.toFixed(2)}ms`)
|
||||
|
||||
// 5. Verify round-trip
|
||||
const nc = decoded.nodeChanges
|
||||
if (!nc || nc.length !== changes.length) {
|
||||
console.error(`❌ Node count mismatch: expected ${changes.length}, got ${nc?.length}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
for (let i = 0; i < nc.length; i++) {
|
||||
const original = changes[i]!
|
||||
const roundtripped = nc[i]!
|
||||
|
||||
// Verify GUID
|
||||
if (
|
||||
roundtripped.guid?.sessionID !== original.guid.sessionID ||
|
||||
roundtripped.guid?.localID !== original.guid.localID
|
||||
) {
|
||||
console.error(`❌ Node ${i} GUID mismatch`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Verify name
|
||||
if (roundtripped.name !== original.name) {
|
||||
console.error(`❌ Node ${i} name mismatch: "${roundtripped.name}" !== "${original.name}"`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Verify type
|
||||
if (roundtripped.type !== original.type) {
|
||||
console.error(`❌ Node ${i} type mismatch: "${roundtripped.type}" !== "${original.type}"`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Verify size
|
||||
if (roundtripped.size?.x !== original.size?.x || roundtripped.size?.y !== original.size?.y) {
|
||||
console.error(`❌ Node ${i} size mismatch`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Verify transform (position)
|
||||
if (
|
||||
roundtripped.transform?.m02 !== original.transform?.m02 ||
|
||||
roundtripped.transform?.m12 !== original.transform?.m12
|
||||
) {
|
||||
console.error(`❌ Node ${i} position mismatch`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Verify fill
|
||||
if (original.fillPaints && original.fillPaints.length > 0) {
|
||||
const origFill = original.fillPaints[0]!
|
||||
const rtFill = roundtripped.fillPaints?.[0]
|
||||
if (!rtFill) {
|
||||
console.error(`❌ Node ${i} missing fill`)
|
||||
process.exit(1)
|
||||
}
|
||||
if (rtFill.type !== origFill.type) {
|
||||
console.error(`❌ Node ${i} fill type mismatch`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
` ✓ Node ${i}: ${roundtripped.name} (${roundtripped.type}) @ ${roundtripped.transform?.m02},${roundtripped.transform?.m12} [${roundtripped.size?.x}×${roundtripped.size?.y}]`
|
||||
)
|
||||
}
|
||||
|
||||
console.log(`\n✅ PoC 3 PASSED: ${changes.length} nodes round-tripped through Kiwi+Zstd`)
|
||||
console.log(` Schema: 194 definitions`)
|
||||
console.log(` Encode: ${encodeTime.toFixed(2)}ms → ${encoded.byteLength} bytes`)
|
||||
console.log(` Decode: ${decodeTime.toFixed(2)}ms`)
|
||||
}
|
||||
|
||||
runTest().catch((e) => {
|
||||
console.error('❌ PoC 3 FAILED:', e)
|
||||
process.exit(1)
|
||||
})
|
||||
238
src/kiwi/protocol.ts
Normal file
238
src/kiwi/protocol.ts
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
/**
|
||||
* Figma Multiplayer Protocol
|
||||
*
|
||||
* This module handles the low-level WebSocket communication with Figma's
|
||||
* multiplayer server. The protocol uses:
|
||||
*
|
||||
* - Kiwi binary serialization (schema-based, like Protocol Buffers)
|
||||
* - Zstd compression for all messages
|
||||
* - Session-based authentication via cookies
|
||||
*
|
||||
* Message types (from Figma's schema):
|
||||
* 0 = JOIN_START - Server sends session info
|
||||
* 1 = NODE_CHANGES - Create/update/delete nodes
|
||||
* 2 = USER_CHANGES - User presence updates
|
||||
* 3 = JOIN_END - Initial sync complete
|
||||
* 4 = SIGNAL - Various metadata (reconnect info, etc.)
|
||||
* 5 = STYLE - Style updates
|
||||
* ...and more
|
||||
*
|
||||
* Wire format:
|
||||
* All messages are Zstd-compressed Kiwi-encoded binary data.
|
||||
* Zstd magic bytes: 0x28 0xB5 0x2F 0xFD
|
||||
*/
|
||||
|
||||
export const MESSAGE_TYPES = {
|
||||
JOIN_START: 0,
|
||||
NODE_CHANGES: 1,
|
||||
USER_CHANGES: 2,
|
||||
JOIN_END: 3,
|
||||
SIGNAL: 4,
|
||||
STYLE: 5,
|
||||
STYLE_SET: 6,
|
||||
JOIN_START_SKIP_RELOAD: 7,
|
||||
NOTIFY_SHOULD_UPGRADE: 8,
|
||||
UPGRADE_DONE: 9,
|
||||
UPGRADE_REFRESH: 10,
|
||||
SCENE_GRAPH_QUERY: 11,
|
||||
SCENE_GRAPH_REPLY: 12,
|
||||
DIFF: 13,
|
||||
CLIENT_BROADCAST: 14
|
||||
} as const
|
||||
|
||||
export const NODE_TYPES = {
|
||||
NONE: 0,
|
||||
DOCUMENT: 1,
|
||||
CANVAS: 2,
|
||||
GROUP: 3,
|
||||
FRAME: 4,
|
||||
BOOLEAN_OPERATION: 5,
|
||||
VECTOR: 6,
|
||||
STAR: 7,
|
||||
LINE: 8,
|
||||
ELLIPSE: 9,
|
||||
RECTANGLE: 10,
|
||||
REGULAR_POLYGON: 11,
|
||||
ROUNDED_RECTANGLE: 12,
|
||||
TEXT: 13,
|
||||
SLICE: 14,
|
||||
SYMBOL: 15,
|
||||
INSTANCE: 16,
|
||||
STICKY: 17,
|
||||
SHAPE_WITH_TEXT: 18,
|
||||
CONNECTOR: 19,
|
||||
CODE_BLOCK: 20,
|
||||
WIDGET: 21,
|
||||
STAMP: 22,
|
||||
MEDIA: 23,
|
||||
HIGHLIGHT: 24,
|
||||
SECTION: 25,
|
||||
SECTION_OVERLAY: 26,
|
||||
WASHI_TAPE: 27,
|
||||
VARIABLE: 28
|
||||
} as const
|
||||
|
||||
export const NODE_PHASES = {
|
||||
CREATED: 0,
|
||||
REMOVED: 1
|
||||
} as const
|
||||
|
||||
export const BLEND_MODES = {
|
||||
PASS_THROUGH: 0,
|
||||
NORMAL: 1,
|
||||
DARKEN: 2,
|
||||
MULTIPLY: 3,
|
||||
LINEAR_BURN: 4,
|
||||
COLOR_BURN: 5,
|
||||
LIGHTEN: 6,
|
||||
SCREEN: 7,
|
||||
LINEAR_DODGE: 8,
|
||||
COLOR_DODGE: 9,
|
||||
OVERLAY: 10,
|
||||
SOFT_LIGHT: 11,
|
||||
HARD_LIGHT: 12,
|
||||
DIFFERENCE: 13,
|
||||
EXCLUSION: 14,
|
||||
HUE: 15,
|
||||
SATURATION: 16,
|
||||
COLOR: 17,
|
||||
LUMINOSITY: 18
|
||||
} as const
|
||||
|
||||
export const PAINT_TYPES = {
|
||||
SOLID: 0,
|
||||
GRADIENT_LINEAR: 1,
|
||||
GRADIENT_RADIAL: 2,
|
||||
GRADIENT_ANGULAR: 3,
|
||||
GRADIENT_DIAMOND: 4,
|
||||
IMAGE: 5,
|
||||
EMOJI: 6,
|
||||
VIDEO: 7
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Zstd magic bytes
|
||||
*/
|
||||
export const ZSTD_MAGIC = new Uint8Array([0x28, 0xb5, 0x2f, 0xfd])
|
||||
|
||||
// ============================================================================
|
||||
// Kiwi Binary Format Constants
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Kiwi uses field numbers to identify message fields.
|
||||
* Field 1 with value = message type indicates the message kind.
|
||||
*/
|
||||
export const KIWI = {
|
||||
/** First byte of valid Kiwi messages (field number 1) */
|
||||
MESSAGE_MARKER: 1,
|
||||
|
||||
/** Field number for sessionID in JOIN_START message */
|
||||
SESSION_ID_FIELD: 2,
|
||||
|
||||
/** Varint continuation bit (MSB set = more bytes follow) */
|
||||
VARINT_CONTINUE_BIT: 0x80,
|
||||
|
||||
/** Varint value mask (lower 7 bits contain data) */
|
||||
VARINT_VALUE_MASK: 0x7f,
|
||||
|
||||
/** Bits per varint byte */
|
||||
VARINT_BITS_PER_BYTE: 7
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Valid session ID range (based on observed Figma behavior)
|
||||
*/
|
||||
export const SESSION_ID = {
|
||||
MIN: 10000,
|
||||
MAX: 1000000
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Parse a varint from a Uint8Array at given position
|
||||
* Returns [value, newPosition]
|
||||
*/
|
||||
export function parseVarint(data: Uint8Array, pos: number): [number, number] {
|
||||
let value = 0
|
||||
let shift = 0
|
||||
|
||||
while (pos < data.length) {
|
||||
const byte = data[pos]
|
||||
if (byte === undefined) break
|
||||
pos++
|
||||
value |= (byte & KIWI.VARINT_VALUE_MASK) << shift
|
||||
|
||||
if (!(byte & KIWI.VARINT_CONTINUE_BIT)) {
|
||||
break
|
||||
}
|
||||
shift += KIWI.VARINT_BITS_PER_BYTE
|
||||
}
|
||||
|
||||
return [value, pos]
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if data is a valid Kiwi message
|
||||
*/
|
||||
export function isKiwiMessage(data: Uint8Array): boolean {
|
||||
return data.length >= 2 && data[0] === KIWI.MESSAGE_MARKER
|
||||
}
|
||||
|
||||
/**
|
||||
* Get message type from Kiwi message
|
||||
*/
|
||||
export function getKiwiMessageType(data: Uint8Array): number | null {
|
||||
if (!isKiwiMessage(data)) return null
|
||||
return data[1] ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* fig-wire header magic (first 8 bytes of some messages)
|
||||
*/
|
||||
export const FIG_WIRE_MAGIC = 'fig-wire'
|
||||
|
||||
/**
|
||||
* Check if data is Zstd-compressed
|
||||
*/
|
||||
export function isZstdCompressed(data: Uint8Array): boolean {
|
||||
return (
|
||||
data.length >= 4 && data[0] === 0x28 && data[1] === 0xb5 && data[2] === 0x2f && data[3] === 0xfd
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if data has fig-wire header
|
||||
*/
|
||||
export function hasFigWireHeader(data: Uint8Array): boolean {
|
||||
if (data.length < 8) return false
|
||||
const header = new TextDecoder().decode(data.slice(0, 8))
|
||||
return header === FIG_WIRE_MAGIC
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip fig-wire header and find zstd data
|
||||
* Header format: "fig-wire" (8 bytes) + version (4 bytes LE) + zstd data
|
||||
*/
|
||||
export function skipFigWireHeader(data: Uint8Array): Uint8Array {
|
||||
if (!hasFigWireHeader(data)) return data
|
||||
// Skip 8 bytes header + 4 bytes version
|
||||
return data.slice(12)
|
||||
}
|
||||
|
||||
/**
|
||||
* Current multiplayer protocol version
|
||||
*/
|
||||
export const PROTOCOL_VERSION = 151
|
||||
|
||||
/**
|
||||
* Build WebSocket URL for Figma multiplayer
|
||||
*/
|
||||
export function buildMultiplayerUrl(fileKey: string, trackingId?: string): string {
|
||||
const params = new URLSearchParams({
|
||||
role: 'editor',
|
||||
version: String(PROTOCOL_VERSION),
|
||||
recentReload: '0',
|
||||
tracking_session_id: trackingId || `ws-${Date.now()}`
|
||||
})
|
||||
return `wss://www.figma.com/api/multiplayer/${fileKey}?${params}`
|
||||
}
|
||||
2178
src/kiwi/schema.ts
Normal file
2178
src/kiwi/schema.ts
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue