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:
Danila Poyarkov 2026-02-27 17:16:21 +03:00
parent c123dad50c
commit ec90f35df8
10 changed files with 3801 additions and 1 deletions

View file

@ -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=="],
}
}

View file

@ -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"
}
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff