UNPKG

@tanstack/start-client-core

Version:

Modern and scalable routing for React applications

282 lines (260 loc) 7.85 kB
import { FRAME_HEADER_SIZE, FRAME_TYPE_CHUNK, FRAME_TYPE_END, FRAME_TYPE_ERROR, FRAME_TYPE_JSON, MAX_FRAMED_STREAMS, MAX_FRAME_PAYLOAD_SIZE, MAX_UNREAD_RAW_STREAM_BYTES, } from '../constants' const decoder = new TextDecoder() const empty: Uint8Array = new Uint8Array() // With a zero high-water mark, `-desiredSize` is the unread byte count. const rawStreamQueue = new ByteLengthQueuingStrategy({ highWaterMark: 0 }) type Closed = 1 type Failed = readonly [unknown] type State = 0 | Closed | Failed type RawController = | ReadableStreamDefaultController<Uint8Array> | null // canceled by its consumer | false // ended by the wire or decoder type RawEntry = [ReadableStream<Uint8Array>, RawController] export function createFrameDecoder(input: ReadableStream<Uint8Array>) { const reader = input.getReader() const rawStreams = new Map<number, RawEntry>() let state = 0 as State let resume: (() => void) | undefined let jsonController!: ReadableStreamDefaultController<string> const wake = () => { resume?.() resume = undefined } const settleRaw = (entry: RawEntry, terminal: Closed | Failed) => { const controller = entry[1] entry[1] = false if (controller) { if (terminal === 1) { controller.close() } else { controller.error(terminal[0]) } } } const chunks = new ReadableStream<string>({ start(controller) { jsonController = controller }, pull: wake, cancel(reason) { // Every raw stream, including one requested later, ends with this reason. const failed: Failed = [ reason === undefined ? new Error('Framed response cancelled') : reason, ] state = failed wake() void reader.cancel(reason).catch(() => {}) for (const entry of rawStreams.values()) { settleRaw(entry, failed) } }, }) function getRaw(id: number): RawEntry { const existing = rawStreams.get(id) if (existing) { return existing } if (rawStreams.size >= MAX_FRAMED_STREAMS) { throw new Error('Too many raw streams') } let controller!: ReadableStreamDefaultController<Uint8Array> const stream = new ReadableStream<Uint8Array>( { start(value) { controller = value }, cancel() { if (entry[1] !== false) { entry[1] = null } }, }, rawStreamQueue, ) const entry: RawEntry = [stream, controller] rawStreams.set(id, entry) if (state !== 0) { settleRaw(entry, state) } return entry } function getStream(id: number): ReadableStream<Uint8Array> { if (id === 0 || id >>> 0 !== id) { throw new RangeError('Invalid raw stream ID') } return getRaw(id)[0] } void (async () => { let inputChunk = empty let inputOffset = 0 async function more(): Promise<boolean> { while (inputOffset === inputChunk.byteLength) { inputChunk = empty inputOffset = 0 const next = await reader.read() if (state !== 0 || next.done) { return false } inputChunk = next.value } return true } async function read( length: number, cleanEof?: boolean, ): Promise<Uint8Array | undefined> { if (length === 0) { return empty } if (!(await more())) { if (cleanEof) { return } throw new Error('Incomplete frame') } const available = inputChunk.byteLength - inputOffset if (available >= length) { const result = inputChunk.subarray(inputOffset, inputOffset + length) inputOffset += length if (inputOffset === inputChunk.byteLength) { inputChunk = empty inputOffset = 0 } return result } const result = new Uint8Array(length) let offset = 0 while (offset < length) { if (!(await more())) { throw new Error('Incomplete frame') } const size = Math.min( length - offset, inputChunk.byteLength - inputOffset, ) result.set(inputChunk.subarray(inputOffset, inputOffset + size), offset) inputOffset += size offset += size } if (inputOffset === inputChunk.byteLength) { inputChunk = empty inputOffset = 0 } return result } try { while (state === 0) { let header = await read(FRAME_HEADER_SIZE, true) // Cancellation can run while the read is suspended. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (state !== 0) { return } if (!header) { for (const entry of rawStreams.values()) { if (entry[1]) { throw new Error('Incomplete raw stream') } } state = 1 jsonController.close() return } const type = header[0]! const streamId = ((header[1]! << 24) | (header[2]! << 16) | (header[3]! << 8) | header[4]!) >>> 0 const length = ((header[5]! << 24) | (header[6]! << 16) | (header[7]! << 8) | header[8]!) >>> 0 header = empty if ( type > FRAME_TYPE_ERROR || (type === FRAME_TYPE_JSON) !== (streamId === 0) || length > MAX_FRAME_PAYLOAD_SIZE || (type === FRAME_TYPE_END && length !== 0) ) { throw new Error('Invalid frame') } const entry = type === FRAME_TYPE_JSON ? undefined : getRaw(streamId) if (entry?.[1] === false) { throw new Error('Raw stream already ended') } let payload = (await read(length))! // Cancellation can run while the read is suspended. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (state !== 0) { return } if (!entry) { const value = decoder.decode(payload) payload = empty jsonController.enqueue(value) // Cancellation wakes this wait even when JSON demand stays at zero. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition while (state === 0 && jsonController.desiredSize! <= 0) { await new Promise<void>((resolve) => { resume = resolve }) } continue } if (type === FRAME_TYPE_CHUNK) { const controller = entry[1] if (controller) { if (-controller.desiredSize! > MAX_UNREAD_RAW_STREAM_BYTES) { controller.error( new Error(`Raw stream ${streamId} has too many unread bytes`), ) entry[1] = null payload = empty continue } // A small view would pin its whole network buffer; copy those. const chunk = payload.byteLength * 4 < payload.buffer.byteLength ? payload.slice() : payload payload = empty controller.enqueue(chunk) } } else { settleRaw( entry, type === FRAME_TYPE_END ? 1 : [new Error(decoder.decode(payload))], ) } } } catch (error) { if (state === 0) { const failed: Failed = [error] state = failed void reader.cancel(error).catch(() => {}) jsonController.error(error) for (const entry of rawStreams.values()) { settleRaw(entry, failed) } } } finally { inputChunk = empty reader.releaseLock() } })() return [chunks, getStream] as const }