refactor(kiwi): move FIG container helpers

This commit is contained in:
Danila Poyarkov 2026-06-06 13:37:29 +03:00
parent d068237ee4
commit 8365112c51
6 changed files with 128 additions and 75 deletions

View file

@ -1,75 +1,7 @@
import { deflateSync, inflateSync } from 'fflate' export {
buildFigKiwi,
export const FIG_KIWI_DEFAULT_VERSION = 101 decompressFigKiwiData,
decompressFigKiwiDataAsync,
export function parseFigKiwiChunks(binary: Uint8Array): Uint8Array[] | null { FIG_KIWI_DEFAULT_VERSION,
const header = new TextDecoder().decode(binary.slice(0, 8)) parseFigKiwiChunks
if (header !== 'fig-kiwi') return null } from '@open-pencil/kiwi/fig/container'
const view = new DataView(binary.buffer, binary.byteOffset, binary.byteLength)
let offset = 12
const chunks: Uint8Array[] = []
while (offset < binary.length) {
const chunkLen = view.getUint32(offset, true)
offset += 4
chunks.push(binary.slice(offset, offset + chunkLen))
offset += chunkLen
}
return chunks.length >= 2 ? chunks : null
}
export async function decompressFigKiwiDataAsync(compressed: Uint8Array): Promise<Uint8Array> {
if (
compressed.length >= 4 &&
compressed[0] === 0x28 &&
compressed[1] === 0xb5 &&
compressed[2] === 0x2f &&
compressed[3] === 0xfd
) {
const { decompress } = await import('fzstd')
return decompress(compressed)
}
try {
return inflateSync(compressed)
} catch {
const { decompress } = await import('fzstd')
return decompress(compressed)
}
}
export function buildFigKiwi(
schemaDeflated: Uint8Array,
dataRaw: Uint8Array,
version = FIG_KIWI_DEFAULT_VERSION
): Uint8Array {
let dataCompressed: Uint8Array
const zstdCompress: ((data: Uint8Array) => Uint8Array) | undefined = (() => {
const g = globalThis as { Bun?: { zstdCompressSync?: (data: Uint8Array) => Uint8Array } }
return g.Bun?.zstdCompressSync
})()
if (zstdCompress) {
dataCompressed = zstdCompress(dataRaw)
} else {
dataCompressed = deflateSync(dataRaw)
}
const total = 8 + 4 + 4 + schemaDeflated.length + 4 + dataCompressed.length
const out = new Uint8Array(total)
const view = new DataView(out.buffer)
out.set(new TextEncoder().encode('fig-kiwi'), 0)
view.setUint32(8, version, true)
let offset = 12
view.setUint32(offset, schemaDeflated.length, true)
offset += 4
out.set(schemaDeflated, offset)
offset += schemaDeflated.length
view.setUint32(offset, dataCompressed.length, true)
offset += 4
out.set(dataCompressed, offset)
return out
}

View file

@ -28,6 +28,12 @@
"import": "./dist/fig/codec.js", "import": "./dist/fig/codec.js",
"default": "./dist/fig/codec.js" "default": "./dist/fig/codec.js"
}, },
"./fig/container": {
"types": "./dist/src/fig/container.d.ts",
"bun": "./src/fig/container.ts",
"import": "./dist/fig/container.js",
"default": "./dist/fig/container.js"
},
"./fig/parse": { "./fig/parse": {
"types": "./dist/src/fig/parse.d.ts", "types": "./dist/src/fig/parse.d.ts",
"bun": "./src/fig/parse.ts", "bun": "./src/fig/parse.ts",

View file

@ -1,4 +1,5 @@
import { getSchemaBytes, initCodec, isCodecReady } from '../dist/fig/codec.js' import { getSchemaBytes, initCodec, isCodecReady } from '../dist/fig/codec.js'
import { buildFigKiwi, parseFigKiwiChunks } from '../dist/fig/container.js'
import { parseFigKiwiContainer } from '../dist/fig/parse.js' import { parseFigKiwiContainer } from '../dist/fig/parse.js'
import { figmaSchema, getKiwiMessageType, parseSchema, validateSchema } from '../dist/index.js' import { figmaSchema, getKiwiMessageType, parseSchema, validateSchema } from '../dist/index.js'
@ -27,3 +28,8 @@ if (!isCodecReady() || getSchemaBytes().length === 0) {
if (parseFigKiwiContainer(new TextEncoder().encode('not-fig-kiwi')) !== null) { if (parseFigKiwiContainer(new TextEncoder().encode('not-fig-kiwi')) !== null) {
throw new Error('Failed to reject invalid FIG Kiwi container') throw new Error('Failed to reject invalid FIG Kiwi container')
} }
const figKiwi = buildFigKiwi(new Uint8Array([1]), new Uint8Array([2]))
if (parseFigKiwiChunks(figKiwi)?.length !== 2) {
throw new Error('Failed to read built FIG Kiwi container chunks')
}

View file

@ -0,0 +1,64 @@
import { deflateSync, inflateSync } from 'fflate'
export const FIG_KIWI_DEFAULT_VERSION = 101
export function parseFigKiwiChunks(binary: Uint8Array): Uint8Array[] | null {
const header = new TextDecoder().decode(binary.slice(0, 8))
if (header !== 'fig-kiwi') return null
const view = new DataView(binary.buffer, binary.byteOffset, binary.byteLength)
let offset = 12
const chunks: Uint8Array[] = []
while (offset < binary.length) {
const chunkLen = view.getUint32(offset, true)
offset += 4
chunks.push(binary.slice(offset, offset + chunkLen))
offset += chunkLen
}
return chunks.length >= 2 ? chunks : null
}
export function decompressFigKiwiData(compressed: Uint8Array): Uint8Array {
try {
return inflateSync(compressed)
} catch {
throw new Error('Failed to decompress fig-kiwi data')
}
}
export async function decompressFigKiwiDataAsync(compressed: Uint8Array): Promise<Uint8Array> {
try {
return inflateSync(compressed)
} catch {
const fzstd = await import('fzstd')
return fzstd.decompress(compressed)
}
}
export function buildFigKiwi(
schemaDeflated: Uint8Array,
dataRaw: Uint8Array,
version = FIG_KIWI_DEFAULT_VERSION
): Uint8Array {
const dataDeflated = deflateSync(dataRaw)
const total = 8 + 4 + 4 + schemaDeflated.length + 4 + dataDeflated.length
const out = new Uint8Array(total)
const view = new DataView(out.buffer)
out.set(new TextEncoder().encode('fig-kiwi'), 0)
view.setUint32(8, version, true)
let offset = 12
view.setUint32(offset, schemaDeflated.length, true)
offset += 4
out.set(schemaDeflated, offset)
offset += schemaDeflated.length
view.setUint32(offset, dataDeflated.length, true)
offset += 4
out.set(dataDeflated, offset)
return out
}

View file

@ -0,0 +1,44 @@
import { describe, expect, test } from 'bun:test'
import { deflateSync } from 'fflate'
import {
buildFigKiwi,
decompressFigKiwiData,
decompressFigKiwiDataAsync,
FIG_KIWI_DEFAULT_VERSION,
parseFigKiwiChunks
} from '../src/fig/container'
describe('Figma FIG Kiwi container helpers', () => {
test('builds and parses fig-kiwi chunks', () => {
const schemaDeflated = new Uint8Array([1, 2, 3])
const dataRaw = new TextEncoder().encode('payload')
const binary = buildFigKiwi(schemaDeflated, dataRaw)
const chunks = parseFigKiwiChunks(binary)
expect(chunks).not.toBeNull()
expect(chunks?.[0]).toEqual(schemaDeflated)
expect(chunks?.[1]).toEqual(deflateSync(dataRaw))
})
test('uses the default container version', () => {
const binary = buildFigKiwi(new Uint8Array([1]), new Uint8Array([2]))
const view = new DataView(binary.buffer, binary.byteOffset, binary.byteLength)
expect(view.getUint32(8, true)).toBe(FIG_KIWI_DEFAULT_VERSION)
})
test('decompresses deflated data synchronously and asynchronously', async () => {
const dataRaw = new TextEncoder().encode('payload')
const deflated = deflateSync(dataRaw)
expect(decompressFigKiwiData(deflated)).toEqual(dataRaw)
await expect(decompressFigKiwiDataAsync(deflated)).resolves.toEqual(dataRaw)
})
test('rejects non fig-kiwi data', () => {
expect(parseFigKiwiChunks(new TextEncoder().encode('not-fig-kiwi'))).toBeNull()
})
})

View file

@ -21,6 +21,7 @@ export default defineConfig({
'schema-runtime': './src/schema-runtime/index.ts', 'schema-runtime': './src/schema-runtime/index.ts',
fig: './src/fig/index.ts', fig: './src/fig/index.ts',
'fig/codec': './src/fig/codec.ts', 'fig/codec': './src/fig/codec.ts',
'fig/container': './src/fig/container.ts',
'fig/parse': './src/fig/parse.ts' 'fig/parse': './src/fig/parse.ts'
}, },
plugins: [rawText()], plugins: [rawText()],