feat(kiwi): scaffold standalone Kiwi package

This commit is contained in:
Danila Poyarkov 2026-06-06 11:34:33 +03:00
parent 605a404f1e
commit f5bd2425b6
26 changed files with 7311 additions and 11 deletions

View file

@ -105,6 +105,7 @@
},
"dependencies": {
"@open-pencil/core": "workspace:*",
"@open-pencil/dom-css": "workspace:*",
"agentfmt": "^0.1.3",
"canvaskit-wasm": "^0.40.0",
"citty": "^0.1.6",
@ -173,6 +174,15 @@
"@open-pencil/core": "workspace:*",
},
},
"packages/kiwi": {
"name": "@open-pencil/kiwi",
"version": "0.13.2",
"devDependencies": {
"@types/bun": "^1.3.14",
"tsdown": "^0.21.7",
"typescript": "~5.8.3",
},
},
"packages/mcp": {
"name": "@open-pencil/mcp",
"version": "0.13.2",
@ -782,6 +792,8 @@
"@open-pencil/dom-css": ["@open-pencil/dom-css@workspace:packages/dom-css"],
"@open-pencil/kiwi": ["@open-pencil/kiwi@workspace:packages/kiwi"],
"@open-pencil/mcp": ["@open-pencil/mcp@workspace:packages/mcp"],
"@open-pencil/vue": ["@open-pencil/vue@workspace:packages/vue"],
@ -1114,7 +1126,7 @@
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/culori": ["@types/culori@4.0.1", "", {}, "sha512-43M51r/22CjhbOXyGT361GZ9vncSVQ39u62x5eJdBQFviI8zWp2X5jzqg7k4M6PVgDQAClpy2bUe2dtwEgEDVQ=="],
@ -1366,7 +1378,7 @@
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
@ -2952,8 +2964,6 @@
"@multiformats/dns/@libp2p/interface": ["@libp2p/interface@3.1.0", "", { "dependencies": { "@multiformats/dns": "^1.0.6", "@multiformats/multiaddr": "^13.0.1", "main-event": "^1.0.1", "multiformats": "^13.4.0", "progress-events": "^1.0.1", "uint8arraylist": "^2.4.8" } }, "sha512-RE7/XyvC47fQBe1cHxhMvepYKa5bFCUyFrrpj8PuM0E7JtzxU7F+Du5j4VXbg2yLDcToe0+j8mB7jvwE2AThYw=="],
"@open-pencil/dom-css/@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@open-pencil/mcp/@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="],
"@rollup/plugin-node-resolve/@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="],
@ -3170,8 +3180,6 @@
"@multiformats/dns/@libp2p/interface/@multiformats/multiaddr": ["@multiformats/multiaddr@13.0.1", "", { "dependencies": { "@chainsafe/is-ip": "^2.0.1", "multiformats": "^13.0.0", "uint8-varint": "^2.0.1", "uint8arrays": "^5.0.0" } }, "sha512-XToN915cnfr6Lr9EdGWakGJbPT0ghpg/850HvdC+zFX8XvpLZElwa8synCiwa8TuvKNnny6m8j8NVBNCxhIO3g=="],
"@open-pencil/dom-css/@types/bun/bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"@open-pencil/mcp/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"@vue-macros/common/ast-kit/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="],

View file

@ -418,7 +418,8 @@
},
{
"files": [
"**/kiwi/schema-runtime/**"
"**/kiwi/schema-runtime/**",
"**/kiwi/src/schema-runtime/**"
],
"rules": {
"typescript/no-explicit-any": "off",

View file

@ -12,9 +12,9 @@
"build": "bun run build:packages && bun run lint && vite build",
"preview": "vite preview",
"tauri": "tauri",
"lint": "bun run lint:structure && oxlint -c oxlint.json --type-aware --type-check src/ packages/core/src/ packages/vue/src/ packages/cli/src/ packages/mcp/src/ packages/dom-css/src/",
"lint:structure": "oxlint -c oxlint.json vite.config.ts vite/ src/ packages/core/src/ packages/vue/src/ packages/cli/src/ packages/mcp/src/ packages/dom-css/src/ packages/dom-css/tests/ packages/dom-css/scripts/ tests/ scripts/ tools/",
"format": "oxfmt --write .oxfmtrc.json vite.config.ts vite/ src/ packages/core/src/ packages/cli/src/ packages/mcp/src/ packages/vue/src/ packages/dom-css/src/ packages/dom-css/tests/ packages/dom-css/scripts/ tests scripts/ tools/",
"lint": "bun run lint:structure && oxlint -c oxlint.json --type-aware --type-check src/ packages/core/src/ packages/vue/src/ packages/cli/src/ packages/mcp/src/ packages/dom-css/src/ packages/kiwi/src/",
"lint:structure": "oxlint -c oxlint.json vite.config.ts vite/ src/ packages/core/src/ packages/vue/src/ packages/cli/src/ packages/mcp/src/ packages/dom-css/src/ packages/dom-css/tests/ packages/dom-css/scripts/ packages/kiwi/src/ packages/kiwi/tests/ packages/kiwi/scripts/ tests/ scripts/ tools/",
"format": "oxfmt --write .oxfmtrc.json vite.config.ts vite/ src/ packages/core/src/ packages/cli/src/ packages/mcp/src/ packages/vue/src/ packages/dom-css/src/ packages/dom-css/tests/ packages/dom-css/scripts/ packages/kiwi/src/ packages/kiwi/tests/ packages/kiwi/scripts/ tests scripts/ tools/",
"format:check": "bun run format && status=$(git status --porcelain -uall) && test -z \"$status\" || (echo \"$status\" && exit 1)",
"check": "bun run build:packages && bun run lint && tsgo --noEmit && bun run check:vue && bun run check:i18n && bun run check:packages && bun run check:arch && bun run test:type-shapes && bun run test:tools && bun run test:dupes",
"check:i18n": "bun tools/i18n/src/check-locales.ts",
@ -31,7 +31,7 @@
"test:tools": "bun tools/test.ts",
"test:dupes": "jscpd packages/core/src packages/cli/src packages/dom-css/src src --min-lines 5 --min-tokens 50 --format typescript --threshold 0",
"test:packages": "bun tools/package-quality/src/check-metadata.ts && bun tools/package-quality/src/smoke.ts",
"build:packages": "bun --filter @open-pencil/core build && bun --filter @open-pencil/dom-css build && bun --filter @open-pencil/vue build && bun --filter @open-pencil/mcp build && bun --filter @open-pencil/cli build",
"build:packages": "bun --filter @open-pencil/kiwi build && bun --filter @open-pencil/core build && bun --filter @open-pencil/dom-css build && bun --filter @open-pencil/vue build && bun --filter @open-pencil/mcp build && bun --filter @open-pencil/cli build",
"open-pencil": "bun packages/cli/src/index.ts",
"docs:dev": "bun --filter @open-pencil/docs dev",
"docs:build": "bun --filter @open-pencil/docs build",

20
packages/kiwi/README.md Normal file
View file

@ -0,0 +1,20 @@
# @open-pencil/kiwi
Kiwi schema runtime utilities for OpenPencil.
This package is the future home for scene-graph-agnostic Kiwi parsing, validation, and binary codec helpers. The first split keeps `.fig` import/export policy in `@open-pencil/core` while package-local tests prove the pure Kiwi runtime can build and run independently.
## Checks
```sh
cd packages/kiwi
bun run check
```
Package scripts:
- `bun run test` — package-local Bun tests for schema runtime and Figma schema guards
- `bun run typecheck` — type-checks `src`, tests, and package scripts
- `bun run build` — builds the distributable `dist` entrypoints
- `bun run smoke:dist` — imports built output and exercises the public API
- `bun run check` — runs typecheck, tests, build, and dist smoke in sequence

View file

@ -0,0 +1,55 @@
{
"name": "@open-pencil/kiwi",
"version": "0.13.2",
"license": "MIT",
"type": "module",
"exports": {
".": {
"types": "./dist/src/index.d.ts",
"bun": "./src/index.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
},
"./schema-runtime": {
"types": "./dist/src/schema-runtime/index.d.ts",
"bun": "./src/schema-runtime/index.ts",
"import": "./dist/schema-runtime.js",
"default": "./dist/schema-runtime.js"
},
"./fig": {
"types": "./dist/src/fig/index.d.ts",
"bun": "./src/fig/index.ts",
"import": "./dist/fig.js",
"default": "./dist/fig.js"
}
},
"main": "./dist/index.js",
"types": "./dist/src/index.d.ts",
"sideEffects": false,
"files": [
"dist",
"README.md"
],
"scripts": {
"build": "bunx tsdown --config tsdown.config.ts && bunx tsc --emitDeclarationOnly -p tsconfig.json",
"check": "bun run typecheck && bun run test && bun run build && bun run smoke:dist",
"prepack": "bun run build",
"smoke:dist": "bun scripts/smoke-dist.ts",
"test": "bun test tests",
"typecheck": "bunx tsc --noEmit -p tsconfig.test.json"
},
"repository": {
"type": "git",
"url": "git+https://github.com/open-pencil/open-pencil.git",
"directory": "packages/kiwi"
},
"publishConfig": {
"access": "public",
"provenance": true
},
"devDependencies": {
"@types/bun": "^1.3.14",
"tsdown": "^0.21.7",
"typescript": "~5.8.3"
}
}

View file

@ -0,0 +1,17 @@
import { figmaSchema, getKiwiMessageType, parseSchema, validateSchema } from '../dist/index.js'
const schema = parseSchema(`
message Smoke {
uint id = 1;
}
`)
validateSchema(schema)
validateSchema(figmaSchema)
if (figmaSchema.definitions.length !== 605) {
throw new Error(`Unexpected Figma schema definition count: ${figmaSchema.definitions.length}`)
}
if (getKiwiMessageType(new Uint8Array([1, 3, 0])) !== 3) {
throw new Error('Failed to read Kiwi message type from built dist')
}

View file

@ -0,0 +1,2 @@
export { default as figmaSchema } from './schema'
export * from './protocol'

View file

@ -0,0 +1,237 @@
/**
* 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]
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}`
}

View file

@ -0,0 +1,7 @@
import { parseSchema, validateSchema } from '../schema-runtime'
import schemaText from './schema/fig.kiwi?raw'
const schema = parseSchema(schemaText)
validateSchema(schema)
export default schema

File diff suppressed because it is too large Load diff

4
packages/kiwi/src/global.d.ts vendored Normal file
View file

@ -0,0 +1,4 @@
declare module '*.kiwi?raw' {
const content: string
export default content
}

View file

@ -0,0 +1,2 @@
export * from './schema-runtime'
export * from './fig'

View file

@ -0,0 +1,252 @@
let int32 = new Int32Array(1)
let float32 = new Float32Array(int32.buffer)
const textDecoder = new TextDecoder()
export class ByteBuffer {
private _data: Uint8Array
private _index: number
length: number
constructor(data?: Uint8Array) {
if (data && !(data instanceof Uint8Array)) {
throw new Error('Must initialize a ByteBuffer with a Uint8Array')
}
this._data = data || new Uint8Array(256)
this._index = 0
this.length = data ? data.length : 0
}
/**
* Returns a view into the internal buffer, not a copy.
*
* Consumers transferring this Uint8Array across thread boundaries (e.g. via
* postMessage with transferables) MUST copy first: `new Uint8Array(buffer)`.
* Otherwise, if multiple Uint8Arrays share the same underlying ArrayBuffer and
* one is transferred, all views into that buffer become detached.
*/
toUint8Array(): Uint8Array {
return this._data.subarray(0, this.length)
}
readByte(): number {
return this._data[this._index++]
}
readByteArray(): Uint8Array {
const length = this.readVarUint()
const start = this._index
this._index = start + length
return this._data.slice(start, start + length)
}
readVarFloat(): number {
const index = this._index
const data = this._data
const first = data[index]
if (first === 0) {
this._index = index + 1
return 0
}
let bits = first | (data[index + 1] << 8) | (data[index + 2] << 16) | (data[index + 3] << 24)
this._index = index + 4
bits = (bits << 23) | (bits >>> 9)
int32[0] = bits
return float32[0]
}
readVarUint(): number {
const data = this._data
let i = this._index
let b = data[i++]
let value = b & 127
if (b < 128) {
this._index = i
return value
}
b = data[i++]
value |= (b & 127) << 7
if (b < 128) {
this._index = i
return value
}
b = data[i++]
value |= (b & 127) << 14
if (b < 128) {
this._index = i
return value
}
b = data[i++]
value |= (b & 127) << 21
if (b < 128) {
this._index = i
return value
}
b = data[i++]
value |= (b & 127) << 28
this._index = i
return value >>> 0
}
readVarInt(): number {
let value = this.readVarUint() | 0
return value & 1 ? ~(value >>> 1) : value >>> 1
}
readVarUint64(): bigint {
let value = BigInt(0)
let shift = BigInt(0)
let seven = BigInt(7)
let byte: number
while ((byte = this.readByte()) & 128 && shift < 56) {
value |= BigInt(byte & 127) << shift
shift += seven
}
value |= BigInt(byte) << shift
return value
}
readVarInt64(): bigint {
let value = this.readVarUint64()
let one = BigInt(1)
let sign = value & one
value >>= one
return sign ? ~value : value
}
readString(): string {
const data = this._data
const start = this._index
let i = start
while (data[i] !== 0) i++
this._index = i + 1
return textDecoder.decode(data.subarray(start, i))
}
private _growBy(amount: number): void {
if (this.length + amount > this._data.length) {
let data = new Uint8Array((this.length + amount) << 1)
data.set(this._data)
this._data = data
}
this.length += amount
}
writeByte(value: number): void {
let index = this.length
this._growBy(1)
this._data[index] = value
}
writeByteArray(value: Uint8Array): void {
this.writeVarUint(value.length)
let index = this.length
this._growBy(value.length)
this._data.set(value, index)
}
writeVarFloat(value: number): void {
let index = this.length
// Reinterpret as an integer
float32[0] = value
let bits = int32[0]
// Move the exponent to the first 8 bits
bits = (bits >>> 23) | (bits << 9)
// Optimization: use a single byte to store zero and denormals (check for an exponent of 0)
if ((bits & 255) === 0) {
this.writeByte(0)
return
}
// Endian-independent 32-bit write
this._growBy(4)
let data = this._data
data[index] = bits
data[index + 1] = bits >> 8
data[index + 2] = bits >> 16
data[index + 3] = bits >> 24
}
writeVarUint(value: number): void {
if (value < 0 || value > 0xffff_ffff) throw new Error('Outside uint range: ' + value)
do {
let byte = value & 127
value >>>= 7
this.writeByte(value ? byte | 128 : byte)
} while (value)
}
writeVarInt(value: number): void {
if (value < -0x8000_0000 || value > 0x7fff_ffff) throw new Error('Outside int range: ' + value)
this.writeVarUint(((value << 1) ^ (value >> 31)) >>> 0)
}
writeVarUint64(value: bigint | string): void {
if (typeof value === 'string') value = BigInt(value)
else if (typeof value !== 'bigint')
throw new Error(`Expected bigint but got ${typeof value}: ${String(value)}`)
if (value < 0 || value > BigInt('0xFFFFFFFFFFFFFFFF'))
throw new Error('Outside uint64 range: ' + value)
let mask = BigInt(127)
let seven = BigInt(7)
for (let i = 0; value > mask && i < 8; i++) {
this.writeByte(Number(value & mask) | 128)
value >>= seven
}
this.writeByte(Number(value))
}
writeVarInt64(value: bigint | string): void {
if (typeof value === 'string') value = BigInt(value)
else if (typeof value !== 'bigint')
throw new Error(`Expected bigint but got ${typeof value}: ${String(value)}`)
if (value < -BigInt('0x8000000000000000') || value > BigInt('0x7FFFFFFFFFFFFFFF'))
throw new Error('Outside int64 range: ' + value)
let one = BigInt(1)
this.writeVarUint64(value < 0 ? ~(value << one) : value << one)
}
writeString(value: string): void {
let codePoint
for (let i = 0; i < value.length; i++) {
// Decode UTF-16
let a = value.charCodeAt(i)
if (i + 1 === value.length || a < 0xd800 || a >= 0xdc00) {
codePoint = a
} else {
let b = value.charCodeAt(++i)
codePoint = (a << 10) + b + (0x10000 - (0xd800 << 10) - 0xdc00)
}
// Strings are null-terminated
if (codePoint === 0) {
throw new Error('Cannot encode a string containing the null character')
}
// Encode UTF-8
if (codePoint < 0x80) {
this.writeByte(codePoint)
} else {
if (codePoint < 0x800) {
this.writeByte(((codePoint >> 6) & 0x1f) | 0xc0)
} else {
if (codePoint < 0x10000) {
this.writeByte(((codePoint >> 12) & 0x0f) | 0xe0)
} else {
this.writeByte(((codePoint >> 18) & 0x07) | 0xf0)
this.writeByte(((codePoint >> 12) & 0x3f) | 0x80)
}
this.writeByte(((codePoint >> 6) & 0x3f) | 0x80)
}
this.writeByte((codePoint & 0x3f) | 0x80)
}
}
// Strings are null-terminated
this.writeByte(0)
}
}

View file

@ -0,0 +1,102 @@
import { ByteBuffer } from './bb'
import { Schema, Field, Definition, DefinitionKind } from './schema'
let types: (string | null)[] = ['bool', 'byte', 'int', 'uint', 'float', 'string', 'int64', 'uint64']
let kinds: DefinitionKind[] = ['ENUM', 'STRUCT', 'MESSAGE']
export function decodeBinarySchema(buffer: Uint8Array | ByteBuffer): Schema {
let bb = buffer instanceof ByteBuffer ? buffer : new ByteBuffer(buffer)
let definitionCount = bb.readVarUint()
let definitions: Definition[] = []
// Read in the schema
for (let i = 0; i < definitionCount; i++) {
let definitionName = bb.readString()
let kind = bb.readByte()
let fieldCount = bb.readVarUint()
let fields: Field[] = []
for (let j = 0; j < fieldCount; j++) {
let fieldName = bb.readString()
let type = bb.readVarInt()
let isArray = !!(bb.readByte() & 1)
let value = bb.readVarUint()
fields.push({
name: fieldName,
line: 0,
column: 0,
type: kinds[kind] === 'ENUM' ? null : (type as any),
isArray: isArray,
isDeprecated: false,
value: value
})
}
definitions.push({
name: definitionName,
line: 0,
column: 0,
kind: kinds[kind],
fields: fields
})
}
// Bind type names afterwards
for (let i = 0; i < definitionCount; i++) {
let fields = definitions[i].fields
for (let j = 0; j < fields.length; j++) {
let field = fields[j]
let type = field.type as any as number | null
if (type !== null && type < 0) {
if (~type >= types.length) {
throw new Error('Invalid type ' + type)
}
field.type = types[~type]
} else {
if (type !== null && type >= definitions.length) {
throw new Error('Invalid type ' + type)
}
field.type = type === null ? null : definitions[type].name
}
}
}
return {
package: null,
definitions: definitions
}
}
export function encodeBinarySchema(schema: Schema): Uint8Array {
let bb = new ByteBuffer()
let definitions = schema.definitions
let definitionIndex: { [name: string]: number } = {}
bb.writeVarUint(definitions.length)
for (let i = 0; i < definitions.length; i++) {
definitionIndex[definitions[i].name] = i
}
for (let i = 0; i < definitions.length; i++) {
let definition = definitions[i]
bb.writeString(definition.name)
bb.writeByte(kinds.indexOf(definition.kind))
bb.writeVarUint(definition.fields.length)
for (let j = 0; j < definition.fields.length; j++) {
let field = definition.fields[j]
let type = types.indexOf(field.type)
bb.writeString(field.name)
bb.writeVarInt(type === -1 ? definitionIndex[field.type!] : ~type)
bb.writeByte(field.isArray ? 1 : 0)
bb.writeVarUint(field.value)
}
}
return bb.toUint8Array()
}

View file

@ -0,0 +1,12 @@
export type { Schema, Definition, Field } from './schema'
export { ByteBuffer } from './bb'
export { compileSchema } from './js'
export { decodeBinarySchema, encodeBinarySchema } from './binary'
export { parseSchema } from './parser'
export {
validateSchema,
expectFieldNumber,
expectEnumValue,
findDefinition,
findField
} from './validate'

View file

@ -0,0 +1,339 @@
import { ByteBuffer } from './bb'
import { Schema, Definition } from './schema'
import { error, quote } from './util'
function compileDecode(
definition: Definition,
definitions: { [name: string]: Definition }
): string {
let lines: string[] = []
let indent = ' '
lines.push('function (bb) {')
lines.push(' var result = {};')
lines.push(' if (!(bb instanceof this.ByteBuffer)) {')
lines.push(' bb = new this.ByteBuffer(bb);')
lines.push(' }')
lines.push('')
if (definition.kind === 'MESSAGE') {
lines.push(' while (true) {')
lines.push(' switch (bb.readVarUint()) {')
lines.push(' case 0:')
lines.push(' return result;')
lines.push('')
indent = ' '
}
for (let i = 0; i < definition.fields.length; i++) {
let field = definition.fields[i]
let code: string
switch (field.type) {
case 'bool': {
code = '!!bb.readByte()'
break
}
case 'byte': {
code = 'bb.readByte()' // only used if not array
break
}
case 'int': {
code = 'bb.readVarInt()'
break
}
case 'uint': {
code = 'bb.readVarUint()'
break
}
case 'float': {
code = 'bb.readVarFloat()'
break
}
case 'string': {
code = 'bb.readString()'
break
}
case 'int64': {
code = 'bb.readVarInt64()'
break
}
case 'uint64': {
code = 'bb.readVarUint64()'
break
}
default: {
let type = definitions[field.type!]
if (!type) {
error(
'Invalid type ' + quote(field.type!) + ' for field ' + quote(field.name),
field.line,
field.column
)
} else if (type.kind === 'ENUM') {
code = 'this[' + quote(type.name) + '][bb.readVarUint()]'
} else {
code = 'this[' + quote('decode' + type.name) + '](bb)'
}
}
}
if (definition.kind === 'MESSAGE') {
lines.push(' case ' + field.value + ':')
}
if (field.isArray) {
if (field.isDeprecated) {
if (field.type === 'byte') {
lines.push(indent + 'bb.readByteArray();')
} else {
lines.push(indent + 'var length = bb.readVarUint();')
lines.push(indent + 'while (length-- > 0) ' + code + ';')
}
} else {
if (field.type === 'byte') {
lines.push(indent + 'result[' + quote(field.name) + '] = bb.readByteArray();')
} else {
lines.push(indent + 'var length = bb.readVarUint();')
lines.push(indent + 'var values = result[' + quote(field.name) + '] = Array(length);')
lines.push(indent + 'for (var i = 0; i < length; i++) values[i] = ' + code + ';')
}
}
} else {
if (field.isDeprecated) {
lines.push(indent + code + ';')
} else {
lines.push(indent + 'result[' + quote(field.name) + '] = ' + code + ';')
}
}
if (definition.kind === 'MESSAGE') {
lines.push(' break;')
lines.push('')
}
}
if (definition.kind === 'MESSAGE') {
lines.push(' default:')
lines.push(' throw new Error("Attempted to parse invalid message");')
lines.push(' }')
lines.push(' }')
} else {
lines.push(' return result;')
}
lines.push('}')
return lines.join('\n')
}
function compileEncode(
definition: Definition,
definitions: { [name: string]: Definition }
): string {
let lines: string[] = []
lines.push('function (message, bb) {')
lines.push(' var isTopLevel = !bb;')
lines.push(' if (isTopLevel) bb = new this.ByteBuffer();')
for (let j = 0; j < definition.fields.length; j++) {
let field = definition.fields[j]
let code: string
if (field.isDeprecated) {
continue
}
switch (field.type) {
case 'bool': {
code = 'bb.writeByte(value);'
break
}
case 'byte': {
code = 'bb.writeByte(value);' // only used if not array
break
}
case 'int': {
code = 'bb.writeVarInt(value);'
break
}
case 'uint': {
code = 'bb.writeVarUint(value);'
break
}
case 'float': {
code = 'bb.writeVarFloat(value);'
break
}
case 'string': {
code = 'bb.writeString(value);'
break
}
case 'int64': {
code = 'bb.writeVarInt64(value);'
break
}
case 'uint64': {
code = 'bb.writeVarUint64(value);'
break
}
default: {
let type = definitions[field.type!]
if (!type) {
throw new Error('Invalid type ' + quote(field.type!) + ' for field ' + quote(field.name))
} else if (type.kind === 'ENUM') {
code =
'var encoded = this[' +
quote(type.name) +
'][value]; ' +
'if (encoded === void 0) throw new Error("Invalid value " + JSON.stringify(value) + ' +
quote(' for enum ' + quote(type.name)) +
'); ' +
'bb.writeVarUint(encoded);'
} else {
code = 'this[' + quote('encode' + type.name) + '](value, bb);'
}
}
}
lines.push('')
lines.push(' var value = message[' + quote(field.name) + '];')
lines.push(' if (value != null) {') // Comparing with null using "!=" also checks for undefined
if (definition.kind === 'MESSAGE') {
lines.push(' bb.writeVarUint(' + field.value + ');')
}
if (field.isArray) {
if (field.type === 'byte') {
lines.push(' bb.writeByteArray(value);')
} else {
lines.push(' var values = value, n = values.length;')
lines.push(' bb.writeVarUint(n);')
lines.push(' for (var i = 0; i < n; i++) {')
lines.push(' value = values[i];')
lines.push(' ' + code)
lines.push(' }')
}
} else {
lines.push(' ' + code)
}
if (definition.kind === 'STRUCT') {
lines.push(' } else {')
lines.push(
' throw new Error(' + quote('Missing required field ' + quote(field.name)) + ');'
)
}
lines.push(' }')
}
// A field id of zero is reserved to indicate the end of the message
if (definition.kind === 'MESSAGE') {
lines.push(' bb.writeVarUint(0);')
}
lines.push('')
lines.push(' if (isTopLevel) return bb.toUint8Array();')
lines.push('}')
return lines.join('\n')
}
export function compileSchemaJS(schema: Schema): string {
let definitions: { [name: string]: Definition } = {}
let name = schema.package
let js: string[] = []
if (name !== null) {
js.push('var ' + name + ' = exports || ' + name + ' || {}, exports;')
} else {
js.push('var exports = exports || {};')
name = 'exports'
}
js.push(name + '.ByteBuffer = ' + name + '.ByteBuffer || require("kiwi-schema").ByteBuffer;')
for (let i = 0; i < schema.definitions.length; i++) {
let definition = schema.definitions[i]
definitions[definition.name] = definition
}
for (let i = 0; i < schema.definitions.length; i++) {
let definition = schema.definitions[i]
switch (definition.kind) {
case 'ENUM': {
let value: any = {}
for (let j = 0; j < definition.fields.length; j++) {
let field = definition.fields[j]
value[field.name] = field.value
value[field.value] = field.name
}
js.push(name + '[' + quote(definition.name) + '] = ' + JSON.stringify(value, null, 2) + ';')
break
}
case 'STRUCT':
case 'MESSAGE': {
js.push('')
js.push(
name +
'[' +
quote('decode' + definition.name) +
'] = ' +
compileDecode(definition, definitions) +
';'
)
js.push('')
js.push(
name +
'[' +
quote('encode' + definition.name) +
'] = ' +
compileEncode(definition, definitions) +
';'
)
break
}
default: {
error(
'Invalid definition kind ' + quote(definition.kind),
definition.line,
definition.column
)
break
}
}
}
js.push('')
return js.join('\n')
}
export function compileSchema(schema: Schema): any {
let result = {
ByteBuffer: ByteBuffer
}
new Function('exports', compileSchemaJS(schema))(result)
return result
}

View file

@ -0,0 +1,287 @@
import { Schema, Definition, Field, DefinitionKind } from './schema'
import { error, quote } from './util'
export let nativeTypes = ['bool', 'byte', 'float', 'int', 'int64', 'string', 'uint', 'uint64']
// These are special names on the object returned by compileSchema()
export let reservedNames = ['ByteBuffer', 'package']
let regex = /((?:-|\b)\d+\b|[=;{}]|\[\]|\[deprecated\]|\b[A-Za-z_][A-Za-z0-9_]*\b|\/\/.*|\s+)/g
let identifier = /^[A-Za-z_][A-Za-z0-9_]*$/
let whitespace = /^\/\/.*|\s+$/
let equals = /^=$/
let endOfFile = /^$/
let semicolon = /^;$/
let integer = /^-?\d+$/
let leftBrace = /^\{$/
let rightBrace = /^\}$/
let arrayToken = /^\[\]$/
let enumKeyword = /^enum$/
let structKeyword = /^struct$/
let messageKeyword = /^message$/
let packageKeyword = /^package$/
let deprecatedToken = /^\[deprecated\]$/
interface Token {
text: string
line: number
column: number
}
function tokenize(text: string): Token[] {
let parts = text.split(regex)
let tokens = []
let column = 0
let line = 0
for (let i = 0; i < parts.length; i++) {
let part = parts[i]
// Keep non-whitespace tokens
if (i & 1) {
if (!whitespace.test(part)) {
tokens.push({
text: part,
line: line + 1,
column: column + 1
})
}
}
// Detect syntax errors
else if (part !== '') {
error('Syntax error ' + quote(part), line + 1, column + 1)
}
// Keep track of the line and column counts
let lines = part.split('\n')
if (lines.length > 1) column = 0
line += lines.length - 1
column += lines[lines.length - 1].length
}
// End-of-file token
tokens.push({
text: '',
line: line,
column: column
})
return tokens
}
function parse(tokens: Token[]): Schema {
function current(): Token {
return tokens[index]
}
function eat(test: RegExp): boolean {
if (test.test(current().text)) {
index++
return true
}
return false
}
function expect(test: RegExp, expected: string): void {
if (!eat(test)) {
let token = current()
error('Expected ' + expected + ' but found ' + quote(token.text), token.line, token.column)
}
}
function unexpectedToken(): never {
let token = current()
error('Unexpected token ' + quote(token.text), token.line, token.column)
}
let definitions: Definition[] = []
let packageText = null
let index = 0
if (eat(packageKeyword)) {
packageText = current().text
expect(identifier, 'identifier')
expect(semicolon, '";"')
}
while (index < tokens.length && !eat(endOfFile)) {
let fields: Field[] = []
let kind: DefinitionKind
if (eat(enumKeyword)) kind = 'ENUM'
else if (eat(structKeyword)) kind = 'STRUCT'
else if (eat(messageKeyword)) kind = 'MESSAGE'
else unexpectedToken()
// All definitions start off the same
let name = current()
expect(identifier, 'identifier')
expect(leftBrace, '"{"')
// Parse fields
while (!eat(rightBrace)) {
let type: string | null = null
let isArray = false
let isDeprecated = false
// Enums don't have types
if (kind !== 'ENUM') {
type = current().text
expect(identifier, 'identifier')
isArray = eat(arrayToken)
}
let field = current()
expect(identifier, 'identifier')
// Structs don't have explicit values
let value: Token | null = null
if (kind !== 'STRUCT') {
expect(equals, '"="')
value = current()
expect(integer, 'integer')
if ((+value.text | 0) + '' !== value.text) {
error('Invalid integer ' + quote(value.text), value.line, value.column)
}
}
let deprecated = current()
if (eat(deprecatedToken)) {
if (kind !== 'MESSAGE') {
error('Cannot deprecate this field', deprecated.line, deprecated.column)
}
isDeprecated = true
}
expect(semicolon, '";"')
fields.push({
name: field.text,
line: field.line,
column: field.column,
type: type,
isArray: isArray,
isDeprecated: isDeprecated,
value: value !== null ? +value.text | 0 : fields.length + 1
})
}
definitions.push({
name: name.text,
line: name.line,
column: name.column,
kind: kind,
fields: fields
})
}
return {
package: packageText,
definitions: definitions
}
}
function verify(root: Schema): void {
let definedTypes = nativeTypes.slice()
let definitions: { [name: string]: Definition } = {}
// Define definitions
for (let i = 0; i < root.definitions.length; i++) {
let definition = root.definitions[i]
if (definedTypes.includes(definition.name)) {
error(
'The type ' + quote(definition.name) + ' is defined twice',
definition.line,
definition.column
)
}
if (reservedNames.includes(definition.name)) {
error(
'The type name ' + quote(definition.name) + ' is reserved',
definition.line,
definition.column
)
}
definedTypes.push(definition.name)
definitions[definition.name] = definition
}
// Check fields
for (let i = 0; i < root.definitions.length; i++) {
let definition = root.definitions[i]
let fields = definition.fields
if (definition.kind === 'ENUM' || fields.length === 0) {
continue
}
// Check types
for (let j = 0; j < fields.length; j++) {
let field = fields[j]
if (!definedTypes.includes(field.type!)) {
error(
'The type ' + quote(field.type!) + ' is not defined for field ' + quote(field.name),
field.line,
field.column
)
}
}
// Check values
let values: number[] = []
for (let j = 0; j < fields.length; j++) {
let field = fields[j]
if (values.includes(field.value)) {
error('The id for field ' + quote(field.name) + ' is used twice', field.line, field.column)
}
if (field.value <= 0) {
error(
'The id for field ' + quote(field.name) + ' must be positive',
field.line,
field.column
)
}
// Figma schema uses sparse field IDs (up to 435), so skip sequential check
values.push(field.value)
}
}
// Check that structs don't contain themselves
let state: { [name: string]: number } = {}
let check = (name: string): boolean => {
let definition = definitions[name]
if (definition && definition.kind === 'STRUCT') {
if (state[name] === 1) {
error(
'Recursive nesting of ' + quote(name) + ' is not allowed',
definition.line,
definition.column
)
}
if (state[name] !== 2 && definition) {
state[name] = 1
let fields = definition.fields
for (let i = 0; i < fields.length; i++) {
let field = fields[i]
if (!field.isArray) {
check(field.type!)
}
}
state[name] = 2
}
}
return true
}
for (let i = 0; i < root.definitions.length; i++) {
check(root.definitions[i].name)
}
}
export function parseSchema(text: string): Schema {
let schema = parse(tokenize(text))
verify(schema)
return schema
}

View file

@ -0,0 +1,24 @@
export interface Schema {
package: string | null
definitions: Definition[]
}
export type DefinitionKind = 'ENUM' | 'STRUCT' | 'MESSAGE'
export interface Definition {
name: string
line: number
column: number
kind: DefinitionKind
fields: Field[]
}
export interface Field {
name: string
line: number
column: number
type: string | null
isArray: boolean
isDeprecated: boolean
value: number
}

View file

@ -0,0 +1,10 @@
export function quote(text: string): string {
return JSON.stringify(text)
}
export function error(text: string, line: number, column: number): never {
var error = new Error(text)
;(error as any).line = line
;(error as any).column = column
throw error
}

View file

@ -0,0 +1,88 @@
import type { Definition, Field, Schema } from './schema'
import { error, quote } from './util'
export function findDefinition(schema: Schema, name: string): Definition | null {
return schema.definitions.find((definition) => definition.name === name) ?? null
}
export function findField(schema: Schema, definitionName: string, fieldName: string): Field | null {
return (
findDefinition(schema, definitionName)?.fields.find((field) => field.name === fieldName) ?? null
)
}
export function expectFieldNumber(
schema: Schema,
definitionName: string,
fieldName: string,
expectedValue: number
): void {
const field = findField(schema, definitionName, fieldName)
if (!field) {
throw new Error(`Missing field ${definitionName}.${fieldName}`)
}
if (field.value !== expectedValue) {
throw new Error(
`Expected ${definitionName}.${fieldName} to use field ${expectedValue}, got ${field.value}`
)
}
}
export function expectEnumValue(
schema: Schema,
enumName: string,
memberName: string,
expectedValue: number
): void {
const definition = findDefinition(schema, enumName)
if (!definition) {
throw new Error(`Missing enum ${enumName}`)
}
if (definition.kind !== 'ENUM') {
throw new Error(`${enumName} is a ${definition.kind}, not an enum`)
}
const field = definition.fields.find((candidate) => candidate.name === memberName)
if (!field) {
throw new Error(`Missing enum member ${enumName}.${memberName}`)
}
if (field.value !== expectedValue) {
throw new Error(
`Expected ${enumName}.${memberName} to use value ${expectedValue}, got ${field.value}`
)
}
}
export function validateSchema(schema: Schema): void {
for (const definition of schema.definitions) {
validateUniqueFieldNames(definition)
if (definition.kind === 'ENUM') validateUniqueEnumValues(definition)
}
}
function validateUniqueFieldNames(definition: Definition): void {
const fieldsByName = new Set<string>()
for (const field of definition.fields) {
if (fieldsByName.has(field.name)) {
error(
`The field ${quote(field.name)} is defined twice in ${quote(definition.name)}`,
field.line,
field.column
)
}
fieldsByName.add(field.name)
}
}
function validateUniqueEnumValues(definition: Definition): void {
const fieldsByValue = new Set<number>()
for (const field of definition.fields) {
if (fieldsByValue.has(field.value)) {
error(
`The enum value ${field.value} is used twice in ${quote(definition.name)}`,
field.line,
field.column
)
}
fieldsByValue.add(field.value)
}
}

View file

@ -0,0 +1,22 @@
import { describe, expect, test } from 'bun:test'
import { figmaSchema } from '../src/fig'
import { expectEnumValue, expectFieldNumber, validateSchema } from '../src/schema-runtime'
describe('bundled Figma Kiwi schema', () => {
test('validates the static Figma schema', () => {
validateSchema(figmaSchema)
})
test('keeps high-value Figma schema guards stable', () => {
expect(figmaSchema.definitions).toHaveLength(605)
expect(figmaSchema.definitions.some((def) => def.name === 'NodeChange')).toBe(true)
expect(
figmaSchema.definitions.some((def) => def.name === 'InteractiveSlideElementChange')
).toBe(true)
expectFieldNumber(figmaSchema, 'Paint', 'colorVar', 21)
expectFieldNumber(figmaSchema, 'NodeChange', 'pageType', 397)
expectEnumValue(figmaSchema, 'VariableField', 'OVERRIDDEN_SYMBOL_ID', 37)
})
})

View file

@ -0,0 +1,56 @@
import { describe, expect, test } from 'bun:test'
import {
buildMultiplayerUrl,
getKiwiMessageType,
hasFigWireHeader,
isKiwiMessage,
isZstdCompressed,
parseVarint,
skipFigWireHeader,
ZSTD_MAGIC
} from '../src/fig'
describe('Figma Kiwi protocol helpers', () => {
test('detects Zstd magic bytes', () => {
expect(isZstdCompressed(ZSTD_MAGIC)).toBe(true)
expect(isZstdCompressed(new Uint8Array([1, 2, 3, 4]))).toBe(false)
})
test('reads Kiwi message type from minimal payloads', () => {
const payload = new Uint8Array([1, 4, 0])
expect(isKiwiMessage(payload)).toBe(true)
expect(getKiwiMessageType(payload)).toBe(4)
expect(getKiwiMessageType(new Uint8Array([2, 4, 0]))).toBeNull()
})
test('parses representative varints', () => {
expect(parseVarint(new Uint8Array([0]), 0)).toEqual([0, 1])
expect(parseVarint(new Uint8Array([127]), 0)).toEqual([127, 1])
expect(parseVarint(new Uint8Array([128, 1]), 0)).toEqual([128, 2])
})
test('handles fig-wire headers', () => {
const payload = new Uint8Array([
...new TextEncoder().encode('fig-wire'),
1,
0,
0,
0,
40,
181,
47,
253
])
expect(hasFigWireHeader(payload)).toBe(true)
expect(skipFigWireHeader(payload)).toEqual(ZSTD_MAGIC)
})
test('builds multiplayer URLs', () => {
const url = new URL(buildMultiplayerUrl('file-key', 'tracking-id'))
expect(url.protocol).toBe('wss:')
expect(url.pathname).toBe('/api/multiplayer/file-key')
expect(url.searchParams.get('role')).toBe('editor')
expect(url.searchParams.get('tracking_session_id')).toBe('tracking-id')
})
})

View file

@ -0,0 +1,55 @@
import { describe, expect, test } from 'bun:test'
import {
compileSchema,
expectEnumValue,
expectFieldNumber,
parseSchema,
validateSchema
} from '../src/schema-runtime'
const schemaText = `
package Example;
enum Kind {
CARD = 1;
BADGE = 2;
}
message Item {
uint id = 1;
string name = 2;
Kind kind = 3;
string[] tags = 4;
}
`
describe('Kiwi schema runtime', () => {
test('parses and validates inline schemas', () => {
const schema = parseSchema(schemaText)
validateSchema(schema)
expectFieldNumber(schema, 'Item', 'name', 2)
expectFieldNumber(schema, 'Item', 'tags', 4)
expectEnumValue(schema, 'Kind', 'BADGE', 2)
})
test('compiles schemas and round-trips messages', () => {
const schema = parseSchema(schemaText)
interface ItemCodec {
encodeItem(value: unknown): Uint8Array
decodeItem(value: Uint8Array): unknown
}
const codec = compileSchema(schema) as ItemCodec
const encoded = codec.encodeItem({ id: 42, name: 'OpenPencil', kind: 'CARD', tags: ['kiwi'] })
expect(encoded.length).toBeGreaterThan(0)
expect(codec.decodeItem(encoded)).toEqual({
id: 42,
name: 'OpenPencil',
kind: 'CARD',
tags: ['kiwi']
})
})
})

View file

@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": false,
"declaration": true,
"declarationMap": false,
"emitDeclarationOnly": true,
"outDir": "dist",
"rootDir": ".",
"types": ["bun"]
},
"include": ["src/**/*.ts"],
"exclude": ["dist", "tests", "scripts"]
}

View file

@ -0,0 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true
},
"include": ["src/**/*.ts", "tests/**/*.ts", "scripts/**/*.ts"]
}

View file

@ -0,0 +1,56 @@
import { readFileSync } from 'node:fs'
import { defineConfig } from 'tsdown'
import type { Plugin } from 'rolldown'
function rawText(): Plugin {
return {
name: 'raw-text',
load(id) {
if (id.endsWith('?raw')) {
const path = id.slice(0, -'?raw'.length)
return `export default ${JSON.stringify(readFileSync(path, 'utf8'))}`
}
}
}
}
export default defineConfig({
entry: {
index: './src/index.ts',
'schema-runtime': './src/schema-runtime/index.ts',
fig: './src/fig/index.ts'
},
plugins: [rawText()],
platform: 'neutral',
format: ['esm'],
dts: false,
sourcemap: true,
hash: false,
clean: true,
outDir: './dist',
treeshake: {
moduleSideEffects: false
},
deps: {
neverBundle: [/^node:/],
onlyBundle: false
},
outputOptions: {
minifyInternalExports: false,
codeSplitting: {
groups: [
{
test: /(?<!\.d\.c?ts)$/,
name: (id) => {
const cleanId = id.split('?')[0]
const parts = cleanId.split(/[\\/]/g)
const srcIndex = parts.lastIndexOf('src')
const file = srcIndex >= 0 ? parts.slice(srcIndex + 1).join('/') : parts.at(-1) ?? 'chunk'
return `chunks/${file.replace(/\.(ts|tsx)$/, '')}`
}
}
]
}
}
})