@tldraw/sync-core
Version:
tldraw infinite canvas SDK (multiplayer sync).
367 lines (302 loc) • 10.9 kB
text/typescript
import { assert } from 'tldraw'
import { describe, expect, it } from 'vitest'
import { JsonChunkAssembler, chunk } from '../lib/chunk'
describe('chunk', () => {
it('chunks a string', () => {
expect(chunk('hello there my good world', 5)).toMatchInlineSnapshot(`
[
"8_h",
"7_ell",
"6_o t",
"5_her",
"4_e m",
"3_y g",
"2_ood",
"1_ wo",
"0_rld",
]
`)
expect(chunk('hello there my good world', 10)).toMatchInlineSnapshot(`
[
"3_h",
"2_ello the",
"1_re my go",
"0_od world",
]
`)
})
it('does not chunk the string if it is small enough', () => {
const chunks = chunk('hello', 100)
expect(chunks).toMatchInlineSnapshot(`
[
"hello",
]
`)
})
it('makes sure the chunk length does not exceed the given message size', () => {
const chunks = chunk('dark and stormy tonight', 4)
expect(chunks).toMatchInlineSnapshot(`
[
"12_d",
"11_a",
"10_r",
"9_k ",
"8_an",
"7_d ",
"6_st",
"5_or",
"4_my",
"3_ t",
"2_on",
"1_ig",
"0_ht",
]
`)
})
it('does its best if the chunk size is too small', () => {
const chunks = chunk('once upon a time', 1)
expect(chunks).toMatchInlineSnapshot(`
[
"15_o",
"14_n",
"13_c",
"12_e",
"11_ ",
"10_u",
"9_p",
"8_o",
"7_n",
"6_ ",
"5_a",
"4_ ",
"3_t",
"2_i",
"1_m",
"0_e",
]
`)
})
})
const testObject = {} as any
for (let i = 0; i < 1000; i++) {
testObject['key_' + i] = 'value_' + i
}
describe('json unchunker', () => {
it.each([1, 5, 20, 200])('unchunks a json string split at %s bytes', (size) => {
const chunks = chunk(JSON.stringify(testObject), size)
const unchunker = new JsonChunkAssembler()
for (const chunk of chunks.slice(0, -1)) {
const result = unchunker.handleMessage(chunk)
expect(result).toBeNull()
}
expect(unchunker.handleMessage(chunks[chunks.length - 1])).toMatchObject({ data: testObject })
// and the next one should be fine
expect(unchunker.handleMessage('{"ok": true}')).toMatchObject({ data: { ok: true } })
})
it('returns an error if the json is whack', () => {
const chunks = chunk('{"hello": world"}', 5)
const unchunker = new JsonChunkAssembler()
for (const chunk of chunks.slice(0, -1)) {
const result = unchunker.handleMessage(chunk)
expect(result).toBeNull()
}
const node18Error = `Unexpected token w in JSON at position 10`
const node20Error = `Unexpected token 'w', "\\{"hello": world"}" is not valid JSON`
const res = unchunker.handleMessage(chunks[chunks.length - 1])
assert(res, 'expected a result')
assert('error' in res, 'expected an error')
expect(res.error?.message).toMatch(new RegExp(`${node18Error}|${node20Error}`))
// and the next one should be fine
expect(unchunker.handleMessage('{"ok": true}')).toMatchObject({ data: { ok: true } })
})
it('returns an error if one of the chunks was missing', () => {
const chunks = chunk('{"hello": world"}', 10)
const unchunker = new JsonChunkAssembler()
expect(unchunker.handleMessage(chunks[0])).toBeNull()
const res = unchunker.handleMessage(chunks[2])
assert(res, 'expected a result')
assert('error' in res, 'expected an error')
expect(res.error?.message).toMatchInlineSnapshot(`"Chunks received in wrong order"`)
// and the next one should be fine
expect(unchunker.handleMessage('{"ok": true}')).toMatchObject({ data: { ok: true } })
})
it('returns an error if the chunk stream ends abruptly', () => {
const chunks = chunk('{"hello": world"}', 10)
const unchunker = new JsonChunkAssembler()
expect(unchunker.handleMessage(chunks[0])).toBeNull()
expect(unchunker.handleMessage(chunks[1])).toBeNull()
const res = unchunker.handleMessage('{"hello": "world"}')
assert(res, 'expected a result')
assert('error' in res, 'expected an error')
expect(res?.error?.message).toMatchInlineSnapshot(`"Unexpected non-chunk message"`)
// and the next one should be fine
expect(unchunker.handleMessage('{"ok": true}')).toMatchObject({ data: { ok: true } })
})
it('returns an error if the chunk syntax is wrong', () => {
// it only likes json objects
const unchunker = new JsonChunkAssembler()
const res = unchunker.handleMessage('["yo"]')
assert(res, 'expected a result')
assert('error' in res, 'expected an error')
expect(res.error?.message).toMatchInlineSnapshot(`"Invalid chunk: "[\\"yo\\"]...""`)
// and the next one should be fine
expect(unchunker.handleMessage('{"ok": true}')).toMatchObject({ data: { ok: true } })
})
it('handles empty string', () => {
const unchunker = new JsonChunkAssembler()
const result = unchunker.handleMessage('{}')
expect(result).toMatchObject({ data: {}, stringified: '{}' })
})
it('handles complex nested JSON objects', () => {
const complexObject = {
array: [1, 2, { nested: true }],
null: null,
boolean: false,
number: 3.14,
string: 'hello world',
unicode: '🎨🗼️📐',
deep: {
nested: {
object: {
with: 'many levels',
},
},
},
}
const chunks = chunk(JSON.stringify(complexObject), 50)
const unchunker = new JsonChunkAssembler()
for (const chunk of chunks.slice(0, -1)) {
expect(unchunker.handleMessage(chunk)).toBeNull()
}
const result = unchunker.handleMessage(chunks[chunks.length - 1])
expect(result).toMatchObject({ data: complexObject })
})
it('handles state reset after error', () => {
const unchunker = new JsonChunkAssembler()
// Start a chunk sequence
expect(unchunker.handleMessage('1_hello')).toBeNull()
// Send malformed chunk to trigger error
const result = unchunker.handleMessage('invalid_chunk_format')
assert(result && 'error' in result, 'expected error result')
expect(result.error.message).toContain('Invalid chunk')
// Should be able to process normal messages again
expect(unchunker.handleMessage('{"test": true}')).toMatchObject({ data: { test: true } })
})
it('returns error for invalid chunk number format', () => {
const unchunker = new JsonChunkAssembler()
const result = unchunker.handleMessage('abc_invalid_number')
assert(result && 'error' in result, 'expected error result')
expect(result.error.message).toContain('Invalid chunk')
})
it('handles single chunk with number prefix correctly', () => {
const unchunker = new JsonChunkAssembler()
const result = unchunker.handleMessage('0_{"single": "chunk"}')
expect(result).toMatchObject({
data: { single: 'chunk' },
stringified: '{"single": "chunk"}',
})
})
it('handles chunks with empty data parts', () => {
const unchunker = new JsonChunkAssembler()
expect(unchunker.handleMessage('1_')).toBeNull() // empty first chunk
const result = unchunker.handleMessage('0_{"test": true}')
expect(result).toMatchObject({ data: { test: true } })
})
it('handles non-JSON string messages that are not chunks', () => {
const unchunker = new JsonChunkAssembler()
const result = unchunker.handleMessage('not_json_and_not_chunk')
assert(result && 'error' in result, 'expected error result')
expect(result.error.message).toContain('Invalid chunk')
})
it('handles chunk sequence interrupted by JSON message', () => {
const unchunker = new JsonChunkAssembler()
// Start chunk sequence
expect(unchunker.handleMessage('2_hello')).toBeNull()
// Interrupt with JSON message - should trigger error and reset state
const result = unchunker.handleMessage('{"interrupt": true}')
assert(result && 'error' in result, 'expected error result')
expect(result.error.message).toBe('Unexpected non-chunk message')
// Should be able to process messages normally again
expect(unchunker.handleMessage('{"ok": true}')).toMatchObject({ data: { ok: true } })
})
it('handles duplicate or out-of-order chunk numbers', () => {
const unchunker = new JsonChunkAssembler()
// Start with first chunk
expect(unchunker.handleMessage('2_part1')).toBeNull()
// Send chunk with wrong number (should be 1, not 0)
const result = unchunker.handleMessage('0_part3')
assert(result && 'error' in result, 'expected error result')
expect(result.error.message).toBe('Chunks received in wrong order')
})
it('handles JSON parse error in completed chunk sequence', () => {
const unchunker = new JsonChunkAssembler()
// Send chunks that form invalid JSON when combined
expect(unchunker.handleMessage('1_{"invalid":')).toBeNull()
const result = unchunker.handleMessage('0_ }')
assert(result && 'error' in result, 'expected error result')
expect(result.error).toBeInstanceOf(Error)
})
})
describe('chunk function edge cases', () => {
it('handles empty strings', () => {
const result = chunk('', 100)
expect(result).toEqual([''])
})
it('handles single character strings', () => {
const result = chunk('a', 100)
expect(result).toEqual(['a'])
})
it('uses default maxSafeMessageSize when not provided', () => {
// Create a string longer than default max size to test chunking
const longString = 'x'.repeat(262145) // Larger than 262144 default
const result = chunk(longString)
expect(result.length).toBeGreaterThan(1)
expect(result[0]).toMatch(/^\d+_x+$/)
})
it('handles strings exactly at the boundary', () => {
const boundaryString = 'x'.repeat(9) // 9 chars fits in 10 char limit
const result = chunk(boundaryString, 10)
expect(result).toEqual([boundaryString])
})
it('handles strings one character over the boundary', () => {
const overBoundaryString = 'x'.repeat(11)
const result = chunk(overBoundaryString, 10)
expect(result.length).toBeGreaterThan(1)
expect(result[0]).toMatch(/^\d+_x+$/)
})
it('preserves unicode characters correctly', () => {
const unicodeString = '🎨'.repeat(10) + '📐'.repeat(10) + '🗼️'.repeat(10)
const result = chunk(unicodeString, 20)
// Verify chunking works with unicode
expect(result.length).toBeGreaterThan(1)
// Reconstruct and verify
const reconstructed = result
.map((chunk) => {
const match = /^(\d+)_(.*)$/.exec(chunk)
return match ? match[2] : chunk
})
.join('')
expect(reconstructed).toEqual(unicodeString)
})
it('ensures no chunk exceeds maxSafeMessageSize', () => {
const maxSize = 15
const testString = 'hello world this is a long message'
const result = chunk(testString, maxSize)
for (const chunk of result) {
expect(chunk.length).toBeLessThanOrEqual(maxSize)
}
})
it('handles very large strings efficiently', () => {
const veryLargeString = 'a'.repeat(1000000) // 1MB string
const result = chunk(veryLargeString, 10000)
expect(result.length).toBeGreaterThan(1)
// Verify reconstruction works
const reconstructed = result
.map((chunk) => {
const match = /^(\d+)_(.*)$/.exec(chunk)
return match ? match[2] : chunk
})
.join('')
expect(reconstructed).toEqual(veryLargeString)
})
})