Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
217 lines
8.1 KiB
TypeScript
217 lines
8.1 KiB
TypeScript
// quarter of a megabyte, max possible utf-8 string size
|
|
|
|
// cloudflare workers only accept messages of max 1mb
|
|
const MAX_CLIENT_SENT_MESSAGE_SIZE_BYTES = 1024 * 1024
|
|
// utf-8 is max 4 bytes per char
|
|
const MAX_BYTES_PER_CHAR = 4
|
|
|
|
// in the (admittedly impossible) worst case, the max size is 1/4 of a megabyte
|
|
const MAX_SAFE_MESSAGE_SIZE = MAX_CLIENT_SENT_MESSAGE_SIZE_BYTES / MAX_BYTES_PER_CHAR
|
|
|
|
// JsonChunkAssembler holds every chunk of a message until the last one arrives, and the sender
|
|
// decides how many that is — the count is just a prefix on the first chunk. Nothing in the
|
|
// protocol obliges a sender to ever send the last chunk, so without a ceiling a single connection
|
|
// can make the receiver hold an arbitrarily large buffer for as long as the socket stays open.
|
|
//
|
|
// The cap counts UTF-16 code units, which is what a JS string costs in memory: 16M of them is the
|
|
// 32MB of retained string data a room can afford to hold per session and stay inside a Durable
|
|
// Object's 128MB instance limit with room for the document itself. It is far above anything
|
|
// chunk() produces for a real document push. The character cap is the real bound; the chunk-count
|
|
// cap only rejects an absurd declared count on arrival instead of waiting for the characters to
|
|
// accumulate, and is loose enough not to constrain a sender that chunks more finely than the
|
|
// default.
|
|
export const MAX_ASSEMBLED_MESSAGE_CHARS = 16 * 1024 * 1024
|
|
export const MAX_CHUNK_COUNT = 100_000
|
|
|
|
/**
|
|
* Thrown when a message would exceed MAX_ASSEMBLED_MESSAGE_CHARS, on either side of the wire.
|
|
* Distinct from the other assembly errors because it is fatal: retrying sends the same
|
|
* oversized message, so the session is rejected rather than reset.
|
|
*
|
|
* @internal
|
|
*/
|
|
export class MessageTooLargeError extends Error {
|
|
constructor(chars: number) {
|
|
super(`Message too large: ${chars} characters, max ${MAX_ASSEMBLED_MESSAGE_CHARS}`)
|
|
this.name = 'MessageTooLargeError'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Splits a string into smaller chunks suitable for transmission over WebSockets.
|
|
* This function ensures messages don't exceed size limits imposed by platforms like Cloudflare Workers (1MB max).
|
|
* Each chunk is prefixed with a number indicating how many more chunks follow.
|
|
*
|
|
* @param msg - The string to split into chunks
|
|
* @param maxSafeMessageSize - Maximum safe size for each chunk in characters. Defaults to quarter megabyte to account for UTF-8 encoding
|
|
* @returns Array of chunked strings, each prefixed with "\{number\}_" where number indicates remaining chunks
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* // Small message - returns as single chunk
|
|
* chunk('hello world') // ['hello world']
|
|
*
|
|
* // Large message - splits into multiple chunks
|
|
* chunk('very long message...', 10)
|
|
* // ['2_very long', '1_ message', '0_...']
|
|
* ```
|
|
*
|
|
* @internal
|
|
*/
|
|
export function chunk(msg: string, maxSafeMessageSize = MAX_SAFE_MESSAGE_SIZE) {
|
|
if (msg.length < maxSafeMessageSize) {
|
|
return [msg]
|
|
} else {
|
|
const chunks = []
|
|
let chunkNumber = 0
|
|
let offset = msg.length
|
|
while (offset > 0) {
|
|
const prefix = `${chunkNumber}_`
|
|
let chunkSize = Math.max(Math.min(maxSafeMessageSize - prefix.length, offset), 1)
|
|
// Never split a surrogate pair across chunks: WebSocket.send converts each chunk to a
|
|
// USVString, turning a lone surrogate into U+FFFD, so the reassembled JSON silently
|
|
// carries corrupted text. Shrink by one so the pair moves whole into the preceding
|
|
// chunk; a single-character chunk grows by one instead (one over the CH3 bound, see CH9).
|
|
if (
|
|
chunkSize < offset &&
|
|
isLowSurrogate(msg.charCodeAt(offset - chunkSize)) &&
|
|
isHighSurrogate(msg.charCodeAt(offset - chunkSize - 1))
|
|
) {
|
|
chunkSize += chunkSize > 1 ? -1 : 1
|
|
}
|
|
chunks.unshift(prefix + msg.slice(offset - chunkSize, offset))
|
|
offset -= chunkSize
|
|
chunkNumber++
|
|
}
|
|
return chunks
|
|
}
|
|
}
|
|
|
|
function isHighSurrogate(code: number) {
|
|
return code >= 0xd800 && code <= 0xdbff
|
|
}
|
|
|
|
function isLowSurrogate(code: number) {
|
|
return code >= 0xdc00 && code <= 0xdfff
|
|
}
|
|
|
|
// The 's' flag (dotAll) makes '.' match any character including line terminators
|
|
// like U+2028 and U+2029, which are commonly introduced via copy/paste from Word
|
|
const chunkRe = /^(\d+)_(.*)$/s
|
|
|
|
/**
|
|
* Assembles chunked JSON messages back into complete objects.
|
|
* Handles both regular JSON messages and chunked messages created by the chunk() function.
|
|
* Maintains internal state to track partially received chunked messages.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* const assembler = new JsonChunkAssembler()
|
|
*
|
|
* // Handle regular JSON message
|
|
* const result1 = assembler.handleMessage('{"hello": "world"}')
|
|
* // Returns: { data: { hello: "world" }, stringified: '{"hello": "world"}' }
|
|
*
|
|
* // Handle chunked message
|
|
* assembler.handleMessage('1_hello') // Returns: null (partial)
|
|
* const result2 = assembler.handleMessage('0_ world')
|
|
* // Returns: { data: "hello world", stringified: "hello world" }
|
|
* ```
|
|
*
|
|
* @public
|
|
*/
|
|
export class JsonChunkAssembler {
|
|
/**
|
|
* Current assembly state - either 'idle' or tracking chunks being received
|
|
*/
|
|
state:
|
|
| 'idle'
|
|
| {
|
|
chunksReceived: string[]
|
|
totalChunks: number
|
|
charsReceived: number
|
|
} = 'idle'
|
|
|
|
/**
|
|
* Processes a single message, which can be either a complete JSON object or a chunk.
|
|
* For complete JSON objects (starting with '\{'), parses immediately.
|
|
* For chunks (prefixed with "\{number\}_"), accumulates until all chunks received.
|
|
*
|
|
* @param msg - The message to process, either JSON or chunk format
|
|
* @returns Result object with data/stringified on success, error object on failure, or null for incomplete chunks
|
|
* - `\{ data: object, stringified: string \}` - Successfully parsed complete message
|
|
* - `\{ error: Error \}` - Parse error, invalid chunk sequence, or a message exceeding
|
|
* MAX_ASSEMBLED_MESSAGE_CHARS / MAX_CHUNK_COUNT
|
|
* - `null` - Chunk received but more chunks expected
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* const assembler = new JsonChunkAssembler()
|
|
*
|
|
* // Complete JSON message
|
|
* const result = assembler.handleMessage('{"key": "value"}')
|
|
* if (result && 'data' in result) {
|
|
* console.log(result.data) // { key: "value" }
|
|
* }
|
|
*
|
|
* // Chunked message sequence
|
|
* assembler.handleMessage('2_hel') // null - more chunks expected
|
|
* assembler.handleMessage('1_lo ') // null - more chunks expected
|
|
* assembler.handleMessage('0_wor') // { data: "hello wor", stringified: "hello wor" }
|
|
* ```
|
|
*/
|
|
handleMessage(msg: string): { error: Error } | { stringified: string; data: object } | null {
|
|
if (msg.startsWith('{')) {
|
|
const error = this.state === 'idle' ? undefined : new Error('Unexpected non-chunk message')
|
|
this.state = 'idle'
|
|
return error ? { error } : { data: JSON.parse(msg), stringified: msg }
|
|
} else {
|
|
const match = chunkRe.exec(msg)!
|
|
if (!match) {
|
|
this.state = 'idle'
|
|
return { error: new Error('Invalid chunk: ' + JSON.stringify(msg.slice(0, 20) + '...')) }
|
|
}
|
|
const numChunksRemaining = Number(match[1])
|
|
const data = match[2]
|
|
|
|
if (this.state === 'idle') {
|
|
const totalChunks = numChunksRemaining + 1
|
|
if (totalChunks > MAX_CHUNK_COUNT) {
|
|
// Nothing has been buffered yet, so the state is already idle.
|
|
return { error: new Error(`Too many chunks: ${totalChunks}`) }
|
|
}
|
|
this.state = {
|
|
chunksReceived: [data],
|
|
totalChunks,
|
|
charsReceived: data.length,
|
|
}
|
|
} else {
|
|
this.state.chunksReceived.push(data)
|
|
this.state.charsReceived += data.length
|
|
if (numChunksRemaining !== this.state.totalChunks - this.state.chunksReceived.length) {
|
|
this.state = 'idle'
|
|
return { error: new Error(`Chunks received in wrong order`) }
|
|
}
|
|
}
|
|
if (this.state.charsReceived > MAX_ASSEMBLED_MESSAGE_CHARS) {
|
|
// Drop what was buffered rather than keeping it until the socket closes.
|
|
const charsReceived = this.state.charsReceived
|
|
this.state = 'idle'
|
|
return { error: new MessageTooLargeError(charsReceived) }
|
|
}
|
|
if (this.state.chunksReceived.length === this.state.totalChunks) {
|
|
try {
|
|
const stringified = this.state.chunksReceived.join('')
|
|
const data = JSON.parse(stringified)
|
|
return { data, stringified }
|
|
} catch (e) {
|
|
return { error: e as Error }
|
|
} finally {
|
|
this.state = 'idle'
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
}
|
|
}
|