Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Add the dependency to your `build.gradle.kts`:

```kotlin
dependencies {
implementation("dev.kdriver:nextjs:0.2.0")
implementation("dev.kdriver:nextjs:0.2.1")
}
```

Expand Down
9 changes: 8 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
plugins {
alias(libs.plugins.multiplatform) apply false
alias(libs.plugins.maven) apply false
alias(libs.plugins.kover)
alias(libs.plugins.dokka)
}

allprojects {
group = "dev.kdriver"
version = "0.2.0"
version = "0.2.1"
project.ext.set("url", "https://github.com/cdpdriver/kdriver-nextjs")
project.ext.set("license.name", "Apache 2.0")
project.ext.set("license.url", "https://www.apache.org/licenses/LICENSE-2.0.txt")
Expand All @@ -20,3 +21,9 @@ allprojects {
mavenCentral()
}
}

dependencies {
kover(projects.nextjs)
kover(projects.nextjsEngine)
kover(projects.nextjsRsc)
}
8 changes: 5 additions & 3 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
[versions]
kotlin = "2.1.21"
kotlin = "2.3.0"
kover = "0.8.3"
detekt = "1.23.8"
dokka = "2.0.0"
ksp = "2.1.21-2.0.2"
ksp = "2.3.4"
maven = "0.30.0"
kdriver = "0.5.0"
kdriver = "0.5.5"
ktor = "3.4.0"
mockk = "1.13.12"
jsoup = "1.16.2"
coroutines-test = "1.9.0"
Expand All @@ -22,6 +23,7 @@ maven = { id = "com.vanniktech.maven.publish", version.ref = "maven" }

[libraries]
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
ktor-io = { module = "io.ktor:ktor-io", version.ref = "ktor" }
kdriver-core = { group = "dev.kdriver", name = "core", version.ref = "kdriver" }
tests-mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
tests-jsoup = { group = "org.jsoup", name = "jsoup", version.ref = "jsoup" }
Expand Down
1 change: 1 addition & 0 deletions nextjs-rsc/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ kotlin {
val commonMain by getting {
dependencies {
api(libs.kotlinx.serialization.json)
api(libs.ktor.io)
}
}
val jvmTest by getting {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
package dev.kdriver.nextjs.rsc

import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.*

/**
* Resolves RSC references in JSON elements.
Expand All @@ -26,7 +22,7 @@ import kotlinx.serialization.json.JsonPrimitive
*/
class ReferenceResolver(
private val rows: Map<String, RowValue>,
private val maxDepth: Int = 100
private val maxDepth: Int = 100,
) {

/**
Expand All @@ -46,7 +42,6 @@ class ReferenceResolver(
is JsonPrimitive -> resolveReference(element, depth)
is JsonArray -> JsonArray(element.map { resolve(it, depth + 1) })
is JsonObject -> JsonObject(element.mapValues { resolve(it.value, depth + 1) })
else -> element
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package dev.kdriver.nextjs.rsc

import io.ktor.utils.io.core.*

/**
* Parses RSC row format: `id:tag:data` or `id:data` (no tag).
*
Expand Down Expand Up @@ -73,11 +75,64 @@
/**
* Parses multiple row lines from a payload.
*
* Handles T rows with length-encoded text (format: `id:T<hexLen>,<text>`) which
* may contain literal newlines in their content. The hex length is used to read
* the exact byte count of text, skipping past any embedded newlines, rather than
* stopping at the first newline like a naive line-split would.
*
* @param payload The complete payload string with newline-separated rows
* @return List of successfully parsed rows
*/
fun parseRows(payload: String): List<ParsedRow> {

Check warning on line 86 in nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/RowParser.kt

View check run for this annotation

codefactor.io / CodeFactor

nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/RowParser.kt#L86

Function parseRows is nested too deeply. (detekt.NestedBlockDepth)
return payload.split('\n')
.mapNotNull { parseRow(it) }
val results = mutableListOf<ParsedRow>()
var pos = 0

while (pos < payload.length) {

Check warning on line 90 in nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/RowParser.kt

View check run for this annotation

codefactor.io / CodeFactor

nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/RowParser.kt#L90

The loop contains more than one break or continue statement. The code should be refactored to increase readability. (detekt.LoopWithTooManyJumpStatements)
// Skip blank lines
if (payload[pos] == '\n') {
pos++
continue
}

val lineEnd = payload.indexOf('\n', pos)
val line = if (lineEnd == -1) payload.substring(pos) else payload.substring(pos, lineEnd)

if (line.isBlank()) {
pos = if (lineEnd == -1) payload.length else lineEnd + 1
continue
}

// Check if this is a length-encoded T row (may span multiple lines)
val colonIdx = line.indexOf(':')
if (colonIdx > 0) {
val rest = line.substring(colonIdx + 1)
if (rest.isNotEmpty() && rest[0] == 'T') {
val afterT = rest.substring(1)
val commaIdx = afterT.indexOf(',')
if (commaIdx > 0) {
val potentialLength = afterT.substring(0, commaIdx)
if (potentialLength.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }) {
val textByteLength = potentialLength.toLong(16).toInt()

Check warning on line 115 in nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/RowParser.kt

View check run for this annotation

codefactor.io / CodeFactor

nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/RowParser.kt#L115

This expression contains a magic number. Consider defining it to a well named constant. (detekt.MagicNumber)
val textStart = pos + colonIdx + 1 + 1 + commaIdx + 1 // skip id + ':' + 'T' + hexLen + ','
val remaining = payload.substring(textStart)
val remainingBytes = remaining.toByteArray()
if (textByteLength <= remainingBytes.size) {
val text = remainingBytes.decodeToString(0, textByteLength)
results.add(ParsedRow(line.substring(0, colonIdx), 'T', "$potentialLength,$text"))
pos = textStart + text.length
if (pos < payload.length && payload[pos] == '\n') pos++
continue
}
}
}
}
}

// Regular row — parse the current line as-is
parseRow(line)?.let { results.add(it) }
pos = if (lineEnd == -1) payload.length else lineEnd + 1
}

return results
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,56 @@ class FlightPayloadResolverTest {
assertEquals("Custom: Custom data", customRow.value)
}

/**
* Working case: T row with single-line text referenced via direct hex ref ($fe).
* Mirrors Vinted's RSC structure where description is in a T row.
*/
@Test
fun `test direct hex reference to T row with single-line text`() {
val text = "Simple description without newlines"
val hexLen = text.toByteArray().size.toString(16)
val payload = "0:{\"description\":\"\$fe\"}\nfe:T$hexLen,$text"

val resolver = FlightPayloadResolver()
resolver.parsePayloads(createPushesArray(payload))

val rows = resolver.getAllRows()
val textRow = rows["fe"]
assertNotNull(textRow)
assertIs<RowValue.Text>(textRow)
assertEquals(text, textRow.value)

val result = resolver.getResolvedRoot()
assertNotNull(result)
assertEquals(text, result.jsonObject["description"]?.jsonPrimitive?.content)
}

/**
* Failing case: T row with multiline text referenced via direct hex ref ($fe).
* Reproduces the Vinted bug where only the first line of the description is returned.
* After the fix, the full multiline description should be resolved correctly.
*/
@Test
fun `test direct hex reference to T row with multiline text`() {
val text = "First line\nSecond line\nThird line"
val hexLen = text.toByteArray().size.toString(16) // = "21"
val payload = "0:{\"description\":\"\$fe\"}\nfe:T$hexLen,$text"

val resolver = FlightPayloadResolver()
resolver.parsePayloads(createPushesArray(payload))

val rows = resolver.getAllRows()
val textRow = rows["fe"]
assertNotNull(textRow)
assertIs<RowValue.Text>(textRow)
// Should contain the full multiline text, not just "First line"
assertEquals(text, textRow.value)

val result = resolver.getResolvedRoot()
assertNotNull(result)
assertEquals(text, result.jsonObject["description"]?.jsonPrimitive?.content)
}

/**
* Helper to create JsonArray of pushes from payload string.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
package dev.kdriver.nextjs.rsc

import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.*
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
Expand All @@ -20,7 +15,7 @@ class ReferenceResolverTest {
)

val resolver = ReferenceResolver(rows)
val result = resolver.resolve(rows["0"]!!.let { (it as RowValue.Model).json })
val result = resolver.resolve(rows["0"]!!.json)

assertIs<JsonArray>(result)
assertEquals("Hello", result[0].jsonPrimitive.content)
Expand All @@ -35,7 +30,7 @@ class ReferenceResolverTest {
)

val resolver = ReferenceResolver(rows)
val result = resolver.resolve(rows["0"]!!.let { (it as RowValue.Model).json })
val result = resolver.resolve(rows["0"]!!.json)

assertIs<JsonPrimitive>(result)
assertEquals("Final value", result.content)
Expand All @@ -49,7 +44,7 @@ class ReferenceResolverTest {
)

val resolver = ReferenceResolver(rows)
val result = resolver.resolve(rows["0"]!!.let { (it as RowValue.Model).json })
val result = resolver.resolve(rows["0"]!!.json)

assertIs<JsonPrimitive>(result)
assertEquals("Async result", result.content)
Expand All @@ -63,7 +58,7 @@ class ReferenceResolverTest {
)

val resolver = ReferenceResolver(rows)
val result = resolver.resolve(rows["0"]!!.let { (it as RowValue.Model).json })
val result = resolver.resolve(rows["0"]!!.json)

assertIs<JsonPrimitive>(result)
assertEquals("Hex ref value", result.content)
Expand Down Expand Up @@ -91,7 +86,7 @@ class ReferenceResolverTest {
)

val resolver = ReferenceResolver(rows)
val result = resolver.resolve(rows["0"]!!.let { (it as RowValue.Model).json })
val result = resolver.resolve(rows["0"]!!.json)

assertIs<JsonArray>(result)
// First element should be null (undefined)
Expand All @@ -108,7 +103,7 @@ class ReferenceResolverTest {
)

val resolver = ReferenceResolver(rows)
val result = resolver.resolve(rows["0"]!!.let { (it as RowValue.Model).json })
val result = resolver.resolve(rows["0"]!!.json)

// Should resolve the nested reference
assertIs<kotlinx.serialization.json.JsonObject>(result)
Expand All @@ -125,7 +120,7 @@ class ReferenceResolverTest {
)

val resolver = ReferenceResolver(rows)
val result = resolver.resolve(rows["0"]!!.let { (it as RowValue.Model).json })
val result = resolver.resolve(rows["0"]!!.json)

assertIs<JsonArray>(result)
assertEquals(2, result.size)
Expand All @@ -140,7 +135,7 @@ class ReferenceResolverTest {
)

val resolver = ReferenceResolver(rows)
val result = resolver.resolve(rows["0"]!!.let { (it as RowValue.Model).json })
val result = resolver.resolve(rows["0"]!!.json)

assertIs<JsonPrimitive>(result)
assert(result.content.contains("not found"))
Expand Down Expand Up @@ -197,7 +192,7 @@ class ReferenceResolverTest {
)

val resolver = ReferenceResolver(rows, maxDepth = 10)
val result = resolver.resolve(rows["0"]!!.let { (it as RowValue.Model).json })
val result = resolver.resolve(rows["0"]!!.json)

// Should not crash, should return something
assertIs<JsonPrimitive>(result)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,4 +128,34 @@ class RowParserTest {
assertNull(row.tag)
assertEquals("Testing data", row.data)
}

@Test
fun `parseRows handles length-encoded T row with single-line text`() {
val text = "Simple description without newlines"
val hexLen = text.toByteArray().size.toString(16) // = "23"
val payload = "0:{\"ref\":\"\$fe\"}\nfe:T$hexLen,$text"

val rows = RowParser.parseRows(payload)

val feRow = rows.find { it.id == "fe" }
assertNotNull(feRow)
assertEquals('T', feRow.tag)
assertEquals("$hexLen,$text", feRow.data)
}

@Test
fun `parseRows handles length-encoded T row with multiline text`() {
// This is the failing case: multiline T row in same payload
val text = "First line\nSecond line\nThird line"
val hexLen = text.toByteArray().size.toString(16) // = "21"
val payload = "0:{\"ref\":\"\$fe\"}\nfe:T$hexLen,$text"

val rows = RowParser.parseRows(payload)

val feRow = rows.find { it.id == "fe" }
assertNotNull(feRow)
assertEquals('T', feRow.tag)
// The data should contain the full multiline text, not just the first line
assertEquals("$hexLen,$text", feRow.data)
}
}
2 changes: 2 additions & 0 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
rootProject.name = "kdriver-nextjs"
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")

pluginManagement {
repositories {
mavenCentral()
Expand Down