feat(kiwi): scaffold standalone Kiwi package
This commit is contained in:
parent
605a404f1e
commit
f5bd2425b6
20
bun.lock
20
bun.lock
|
|
@ -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=="],
|
||||
|
|
|
|||
|
|
@ -418,7 +418,8 @@
|
|||
},
|
||||
{
|
||||
"files": [
|
||||
"**/kiwi/schema-runtime/**"
|
||||
"**/kiwi/schema-runtime/**",
|
||||
"**/kiwi/src/schema-runtime/**"
|
||||
],
|
||||
"rules": {
|
||||
"typescript/no-explicit-any": "off",
|
||||
|
|
|
|||
|
|
@ -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
20
packages/kiwi/README.md
Normal 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
|
||||
55
packages/kiwi/package.json
Normal file
55
packages/kiwi/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
17
packages/kiwi/scripts/smoke-dist.ts
Normal file
17
packages/kiwi/scripts/smoke-dist.ts
Normal 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')
|
||||
}
|
||||
2
packages/kiwi/src/fig/index.ts
Normal file
2
packages/kiwi/src/fig/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { default as figmaSchema } from './schema'
|
||||
export * from './protocol'
|
||||
237
packages/kiwi/src/fig/protocol.ts
Normal file
237
packages/kiwi/src/fig/protocol.ts
Normal 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}`
|
||||
}
|
||||
7
packages/kiwi/src/fig/schema.ts
Normal file
7
packages/kiwi/src/fig/schema.ts
Normal 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
|
||||
5623
packages/kiwi/src/fig/schema/fig.kiwi
Normal file
5623
packages/kiwi/src/fig/schema/fig.kiwi
Normal file
File diff suppressed because it is too large
Load diff
4
packages/kiwi/src/global.d.ts
vendored
Normal file
4
packages/kiwi/src/global.d.ts
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
declare module '*.kiwi?raw' {
|
||||
const content: string
|
||||
export default content
|
||||
}
|
||||
2
packages/kiwi/src/index.ts
Normal file
2
packages/kiwi/src/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './schema-runtime'
|
||||
export * from './fig'
|
||||
252
packages/kiwi/src/schema-runtime/bb.ts
Normal file
252
packages/kiwi/src/schema-runtime/bb.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
102
packages/kiwi/src/schema-runtime/binary.ts
Normal file
102
packages/kiwi/src/schema-runtime/binary.ts
Normal 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()
|
||||
}
|
||||
12
packages/kiwi/src/schema-runtime/index.ts
Normal file
12
packages/kiwi/src/schema-runtime/index.ts
Normal 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'
|
||||
339
packages/kiwi/src/schema-runtime/js.ts
Normal file
339
packages/kiwi/src/schema-runtime/js.ts
Normal 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
|
||||
}
|
||||
287
packages/kiwi/src/schema-runtime/parser.ts
Normal file
287
packages/kiwi/src/schema-runtime/parser.ts
Normal 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
|
||||
}
|
||||
24
packages/kiwi/src/schema-runtime/schema.ts
Normal file
24
packages/kiwi/src/schema-runtime/schema.ts
Normal 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
|
||||
}
|
||||
10
packages/kiwi/src/schema-runtime/util.ts
Normal file
10
packages/kiwi/src/schema-runtime/util.ts
Normal 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
|
||||
}
|
||||
88
packages/kiwi/src/schema-runtime/validate.ts
Normal file
88
packages/kiwi/src/schema-runtime/validate.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
22
packages/kiwi/tests/fig-schema.test.ts
Normal file
22
packages/kiwi/tests/fig-schema.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
56
packages/kiwi/tests/protocol.test.ts
Normal file
56
packages/kiwi/tests/protocol.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
55
packages/kiwi/tests/schema-runtime.test.ts
Normal file
55
packages/kiwi/tests/schema-runtime.test.ts
Normal 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']
|
||||
})
|
||||
})
|
||||
})
|
||||
14
packages/kiwi/tsconfig.json
Normal file
14
packages/kiwi/tsconfig.json
Normal 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"]
|
||||
}
|
||||
7
packages/kiwi/tsconfig.test.json
Normal file
7
packages/kiwi/tsconfig.test.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts", "scripts/**/*.ts"]
|
||||
}
|
||||
56
packages/kiwi/tsdown.config.ts
Normal file
56
packages/kiwi/tsdown.config.ts
Normal 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)$/, '')}`
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
Loading…
Reference in a new issue