A Kotlin port of the jacob-ebey/turbo-stream decoder. This port is based on version 2.4.1 of the original jacob-ebey/turbo-stream
JavaScript library. Turbo Stream is a library for serializing and deserializing JavaScript/TypeScript objects in a streaming format.
This library allows you to work with JavaScript/TypeScript objects in Kotlin using the Turbo Stream protocol, specifically targeting compatibility with streams generated by jacob-ebey/turbo-stream
version 2.4.1. It provides functionality to decode JavaScript/TypeScript data sent in this streaming format and convert it to corresponding Kotlin objects. This is particularly useful for applications that need to communicate with JavaScript backends or process JavaScript data in a Kotlin multiplatform environment.
Note: This version is not compatible with streams from turbo-stream
3.0.0 or newer.
- Conversion of JavaScript/TypeScript primitive types and objects to their Kotlin equivalents
- Promise support (implemented as Kotlin's
Deferred
) - Custom type decoding plugin system
- Error handling with specific JavaScript error types
- Support for JavaScript types like Symbol, RegExp, BigInt, URL, etc.
Below is the mapping between JavaScript/TypeScript types and their Kotlin equivalents as implemented in this library:
JavaScript/TypeScript Type | Kotlin Type | Notes |
---|---|---|
undefined |
JsUndefined (object) |
Singleton object representing undefined . |
null |
null |
|
boolean |
Boolean |
|
number (integer) |
Int |
|
number (float) |
Double |
|
string |
String |
|
Date |
JsDate |
Wrapper class holding the timestamp as a Double . |
NaN |
Double.NaN |
|
Infinity |
Double.POSITIVE_INFINITY |
|
-Infinity |
Double.NEGATIVE_INFINITY |
|
-0 |
-0.0 |
|
BigInt |
JsBigInt |
Wrapper class holding the BigInt value as a String . |
RegExp |
JsRegExp |
Wrapper class holding the pattern and flags. |
Symbol |
JsSymbol |
Wrapper class holding the symbol's key if registered globally, otherwise description. |
URL |
JsUrl |
Wrapper class holding the URL as a String . |
Array |
List<Any?> |
|
Object |
Map<String, Any?> |
|
Map |
Map<Any?, Any?> |
|
Set |
Set<Any?> |
|
Promise (resolved) |
Deferred (completed) |
Kotlin coroutines Deferred representing the resolved value. |
Promise (rejected) |
Deferred (completed exceptionally) |
Kotlin coroutines Deferred holding the rejection reason. |
Error |
TurboStreamJsError |
Custom error class capturing JS error type and message. |
The library provides several wrapper classes to represent JavaScript types that don't have direct equivalents in Kotlin or require specific handling:
A singleton object representing JavaScript's undefined
value.
object JsUndefined
Represents a JavaScript Date
object. It stores the date as a Double
timestamp (milliseconds since epoch).
data class JsDate(val timestamp: Double)
Example: JsDate(1678886400000.0)
Represents a JavaScript URL
object. It stores the URL as a String
.
data class JsUrl(val url: String)
Example: JsUrl("https://example.com")
Represents a JavaScript BigInt
object. It stores the BigInt value as a String
to maintain precision.
data class JsBigInt(val value: String)
Example: JsBigInt("90071992547409910000")
Represents a JavaScript RegExp
(Regular Expression) object. It stores the pattern
as a String
and flags
as a List<Char>
.
data class JsRegExp(val pattern: String, val flags: List<Char>)
Example: JsRegExp("hello", listOf('g', 'i'))
for /hello/gi
Represents a JavaScript Symbol
object. It stores the symbolFor
string if the symbol is registered in the global symbol registry (using Symbol.for()
), or a description if it's a local symbol.
data class JsSymbol(val symbolFor: String) // Or a description for local symbols
Example: JsSymbol("myGlobalSymbol")
Represents an error that originated from JavaScript. It includes the error message
and optionally the jsErrorType
(e.g., "TypeError", "ReferenceError").
class TurboStreamJsError(
override val message: String?,
val jsErrorType: String?
) : Error()
These wrapper classes ensure that the specific semantics of JavaScript types are preserved when decoded into the Kotlin environment.
To use the Turbo Stream Kotlin decoder, you'll primarily interact with the decode
function. This function takes a Flow<String>
representing the incoming stream of Turbo Stream data and an optional list of DecodePlugin
instances. It returns a DecodeResult<T>
, which contains the decoded value and a Deferred<Unit>
that completes when the entire stream has been processed.
Here's a basic example of how to use the decoder:
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.runBlocking
// Assuming 'decode' and related classes are in scope
fun main() = runBlocking {
// Simulate a simple Turbo Stream flow
val stringFlow = flowOf(
"""["^0",1,2,{"hello":"world"}]"""
)
try {
val result: DecodeResult<List<Any?>> = decode(stringFlow, plugins = null)
// The decoded value is available once the initial part of the stream is processed
val decodedData = result.value
println("Decoded data: $decodedData") // Output: Decoded data: [1, 2, {hello=world}]
// Wait for the entire stream to be processed (e.g., for deferred promises)
result.done.await()
println("Stream processing finished.")
} catch (e: TurboStreamSyntaxError) {
println("Syntax error in Turbo Stream: ${e.message}")
} catch (e: TurboStreamJsError) {
println("JavaScript error in stream: ${e.jsErrorType} - ${e.message}")
} catch (e: Throwable) {
println("An unexpected error occurred: ${e.message}")
}
}
The library supports a plugin system to extend its decoding capabilities. A DecodePlugin
is a function that takes a type identifier string and a list of arguments (decoded from the stream) and can return a Map<String, Any?>
representing the custom decoded object, or null
if the plugin doesn't handle that type.
The signature of a DecodePlugin
is:
typealias DecodePlugin = (String, List<Any?>) -> Map<String, Any?>?
Plugins are useful for:
- Decoding custom JavaScript classes or objects that are not standard JSON types.
- Integrating with domain-specific data formats that might be embedded within the Turbo Stream.
You can pass a list of these plugins to the decode
function. The decoder will try to use these plugins when it encounters a type it doesn't recognize natively.
The decoder can throw specific exceptions:
TurboStreamSyntaxError
: Thrown when the input stream does not conform to the expected Turbo Stream syntax (e.g., invalid JSON, malformed lines).TurboStreamJsError
: Thrown when the stream indicates that a JavaScript error occurred during the serialization or within a Promise that was rejected. This error includes thejsErrorType
(e.g., "TypeError") and the error message from JavaScript.
It's recommended to wrap calls to decode
and result.done.await()
in a try-catch block to handle these potential errors gracefully.
Q: Why? (What's the use case?)
A: React Router / Remix internally uses turbo-stream
for data serialization. This Kotlin decoder library enables Kotlin-based applications, including Android apps, to seamlessly consume data from backends built with React Router or Remix without requiring any special server-side modifications for data formatting. To fetch turbo-stream
encoded data from such a setup, you can typically append .data
to the end of the URL path (e.g., /your/apps/route.data
).
Q: I'm having trouble decoding a stream. What could be the issue?
A: This Kotlin port is based on version 2.4.1 of the original jacob-ebey/turbo-stream
JavaScript library. It is not compatible with streams generated by turbo-stream
version 3.0.0 or newer, as the protocol and features may have changed. Please ensure the stream you are trying to decode was encoded with a compatible version.
Q: Why is this library specifically based on turbo-stream
version 2.4.1?
A: Version 2.4.1 is the specific version of turbo-stream
that is (or was at the time of this library's initial development) used internally by React Router / Remix.
Q: Why are RegExp flags represented as a List<Char>
(e.g., listOf('g', 'i')
) instead of using something like Kotlin's RegexOption
?
A: The representation of RegExp flags as List<Char>
(e.g., JsRegExp("pattern", listOf('g', 'm'))
) was chosen to ensure broader compatibility in Kotlin Multiplatform (KMP) environments. Not all RegexOption
enum values available in Kotlin/JVM are universally supported across all KMP targets (like JavaScript or Native). Storing flags as a list of characters (which directly correspond to the flags in JavaScript's RegExp
constructor like 'g', 'i', 'm', 's', 'u', 'y') provides a more platform-agnostic way to handle regular expressions that originate from JavaScript. This allows the library to accurately represent the intended behavior of the JS RegExp across different Kotlin platforms.
Q: I want to decode the stream directly into my custom class or data class instances. Is this possible?
A: TODO()
MIT