From f62f82ecebe720c14165dbde32cb04c47b1c7bd7 Mon Sep 17 00:00:00 2001 From: NathanFallet Date: Tue, 9 Dec 2025 14:25:40 +0100 Subject: [PATCH 1/5] planning to implement new payload resolver --- nextjs-engine/build.gradle.kts | 23 ++++ .../kdriver/nextjs/AbstractCapturedPushes.kt | 12 +- .../dev/kdriver/nextjs/CapturedPushes.kt | 16 +++ .../kdriver/nextjs/FlightPayloadResolver.kt | 113 ++++++++++++++++++ .../nextjs/AbstractCapturedPushesTest.kt | 2 +- .../nextjs/CapturedPushesFromHtmlTest.kt | 28 ++++- 6 files changed, 186 insertions(+), 8 deletions(-) create mode 100644 nextjs-engine/src/commonMain/kotlin/dev/kdriver/nextjs/FlightPayloadResolver.kt diff --git a/nextjs-engine/build.gradle.kts b/nextjs-engine/build.gradle.kts index 23a374f..344fd90 100644 --- a/nextjs-engine/build.gradle.kts +++ b/nextjs-engine/build.gradle.kts @@ -36,6 +36,29 @@ mavenPublishing { } kotlin { + // Tiers are in accordance with + // Tier 1 + macosX64() + macosArm64() + iosSimulatorArm64() + iosX64() + + // Tier 2 + linuxX64() + linuxArm64() + watchosSimulatorArm64() + watchosX64() + watchosArm32() + watchosArm64() + tvosSimulatorArm64() + tvosX64() + tvosArm64() + iosArm64() + + // Tier 3 + mingwX64() + watchosDeviceArm64() + // jvm & js jvmToolchain(21) jvm { diff --git a/nextjs-engine/src/commonMain/kotlin/dev/kdriver/nextjs/AbstractCapturedPushes.kt b/nextjs-engine/src/commonMain/kotlin/dev/kdriver/nextjs/AbstractCapturedPushes.kt index eacdd10..d8f62a0 100644 --- a/nextjs-engine/src/commonMain/kotlin/dev/kdriver/nextjs/AbstractCapturedPushes.kt +++ b/nextjs-engine/src/commonMain/kotlin/dev/kdriver/nextjs/AbstractCapturedPushes.kt @@ -7,13 +7,13 @@ import kotlinx.serialization.json.* */ abstract class AbstractCapturedPushes : CapturedPushes { - /** - * Provides the next set of push events as a [JsonArray]. - * - * @return A [JsonArray] containing the next push events. - */ - abstract suspend fun provideNextF(): JsonArray + override suspend fun resolvedNextF(): JsonElement { + val resolver = FlightPayloadResolver() + resolver.parsePayloads(provideNextF()) + return resolver.getResolvedRoot() ?: error("No resolved root found") + } + @Deprecated("Use resolvedNextF() for single resolved element.") override suspend fun fetchAll(): List { val jsonArray = provideNextF() val results = mutableListOf() diff --git a/nextjs-engine/src/commonMain/kotlin/dev/kdriver/nextjs/CapturedPushes.kt b/nextjs-engine/src/commonMain/kotlin/dev/kdriver/nextjs/CapturedPushes.kt index a87332f..0fcd3c0 100644 --- a/nextjs-engine/src/commonMain/kotlin/dev/kdriver/nextjs/CapturedPushes.kt +++ b/nextjs-engine/src/commonMain/kotlin/dev/kdriver/nextjs/CapturedPushes.kt @@ -1,5 +1,6 @@ package dev.kdriver.nextjs +import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement /** @@ -7,11 +8,26 @@ import kotlinx.serialization.json.JsonElement */ interface CapturedPushes { + /** + * Provides the next set of push events as a [JsonArray]. + * + * @return A [JsonArray] containing the next push events. + */ + suspend fun provideNextF(): JsonArray + + /** + * Resolves the next push event into a single JSON element. + * + * @return A [JsonElement] representing the resolved next push event. + */ + suspend fun resolvedNextF(): JsonElement + /** * Fetches all captured push events as a list of JSON elements. * * @return A list of JSON elements representing the captured push events. */ + @Deprecated("Use resolvedNextF() for single resolved element.") suspend fun fetchAll(): List } diff --git a/nextjs-engine/src/commonMain/kotlin/dev/kdriver/nextjs/FlightPayloadResolver.kt b/nextjs-engine/src/commonMain/kotlin/dev/kdriver/nextjs/FlightPayloadResolver.kt new file mode 100644 index 0000000..2fdbd05 --- /dev/null +++ b/nextjs-engine/src/commonMain/kotlin/dev/kdriver/nextjs/FlightPayloadResolver.kt @@ -0,0 +1,113 @@ +package dev.kdriver.nextjs + +import kotlinx.serialization.json.* + +class FlightPayloadResolver { + + // Map of row ID -> parsed content + private val rows = mutableMapOf() + + /** + * Parse all the __next_f pushes and build the row map + */ + fun parsePayloads(pushes: JsonArray) { + for (push in pushes) { + val arr = push.jsonArray + val type = arr[0].jsonPrimitive.int + + if (type == 1) { + // Type 1 = row data as string + val payload = arr[1].jsonPrimitive.content + parseRowData(payload) + } + } + } + + private fun parseRowData(payload: String) { + // Each payload can contain multiple lines + for (line in payload.split("\n")) { + if (line.isBlank()) continue + + // Format: id:typeCode:jsonData OR id:jsonData + val colonIndex = line.indexOf(':') + if (colonIndex == -1) continue + + val rowId = line.substring(0, colonIndex) + val rest = line.substring(colonIndex + 1) + + // Check for type code (I, HL, etc.) + val secondColon = rest.indexOf(':') + val jsonData = if (secondColon != -1 && secondColon < 3) { + // Has type code like "I:" or "HL:" + rest.substring(secondColon + 1) + } else { + rest + } + + if (jsonData.isNotBlank()) { + try { + rows[rowId] = Json.parseToJsonElement(jsonData) + } catch (e: Exception) { + // Some rows contain non-JSON data + } + } + } + } + + /** + * Resolve all references in an element recursively + */ + fun resolve(element: JsonElement): JsonElement { + return when (element) { + is JsonPrimitive -> resolveReference(element) + is JsonArray -> JsonArray(element.map { resolve(it) }) + is JsonObject -> JsonObject(element.mapValues { resolve(it.value) }) + else -> element + } + } + + private fun resolveReference(primitive: JsonPrimitive): JsonElement { + if (!primitive.isString) return primitive + + val value = primitive.content + if (!value.startsWith("$")) return primitive + + return when { + // Special literals + value == "\$undefined" -> JsonNull + value == "\$Infinity" -> JsonPrimitive(Double.POSITIVE_INFINITY) + value == "\$-Infinity" -> JsonPrimitive(Double.NEGATIVE_INFINITY) + value == "\$NaN" -> JsonPrimitive(Double.NaN) + + // Lazy reference: $Lxx + value.startsWith("\$L") -> { + val rowId = value.substring(2) + rows[rowId]?.let { resolve(it) } ?: primitive + } + + // Promise reference: $@xx + value.startsWith("\$@") -> { + val rowId = value.substring(2) + rows[rowId]?.let { resolve(it) } ?: primitive + } + + // Server function: $Fxx (usually keep as-is or handle specially) + value.startsWith("\$F") -> primitive + + // Direct reference: $xx (just digits after $) + value.matches(Regex("^\\\$[0-9a-fA-F]+$")) -> { + val rowId = value.substring(1) + rows[rowId]?.let { resolve(it) } ?: primitive + } + + else -> primitive + } + } + + /** + * Get the fully resolved root element + */ + fun getResolvedRoot(): JsonElement? { + return rows["0"]?.let { resolve(it) } + } +} diff --git a/nextjs-engine/src/jvmTest/kotlin/dev/kdriver/nextjs/AbstractCapturedPushesTest.kt b/nextjs-engine/src/jvmTest/kotlin/dev/kdriver/nextjs/AbstractCapturedPushesTest.kt index b22113a..1645749 100644 --- a/nextjs-engine/src/jvmTest/kotlin/dev/kdriver/nextjs/AbstractCapturedPushesTest.kt +++ b/nextjs-engine/src/jvmTest/kotlin/dev/kdriver/nextjs/AbstractCapturedPushesTest.kt @@ -12,7 +12,7 @@ import kotlin.test.assertIs class AbstractCapturedPushesTest { @Test - fun testFetchAll_parsesMultipleJsonElementsFromPayloads() = runTest { + fun testDeprecatedFetchAll_parsesMultipleJsonElementsFromPayloads() = runTest { val sut = object : AbstractCapturedPushes() { override suspend fun provideNextF(): JsonArray { return Serialization.json.parseToJsonElement( diff --git a/nextjs-engine/src/jvmTest/kotlin/dev/kdriver/nextjs/CapturedPushesFromHtmlTest.kt b/nextjs-engine/src/jvmTest/kotlin/dev/kdriver/nextjs/CapturedPushesFromHtmlTest.kt index 844ce37..eb0dea0 100644 --- a/nextjs-engine/src/jvmTest/kotlin/dev/kdriver/nextjs/CapturedPushesFromHtmlTest.kt +++ b/nextjs-engine/src/jvmTest/kotlin/dev/kdriver/nextjs/CapturedPushesFromHtmlTest.kt @@ -9,9 +9,35 @@ import kotlin.test.assertEquals import kotlin.test.assertIs class CapturedPushesFromHtmlTest { + @Test + fun testResolvedFromHtml() = runTest { + val html = """ + + + + + + + + + """.trimIndent() + + val sut = CapturedPushesFromHtml(html) + + val result = sut.resolvedNextF() + + println(result) + } @Test - fun testFetchAll_parsesMultipleJsonElementsFromPayloads() = runTest { + fun testDeprecatedFetchAll_parsesMultipleJsonElementsFromPayloads() = runTest { val html = """ From 677e5b8b5d01220626ab72c28b075ddb4e7a5c6a Mon Sep 17 00:00:00 2001 From: NathanFallet Date: Tue, 9 Dec 2025 15:21:33 +0100 Subject: [PATCH 2/5] draft new implementation --- README.md | 2 +- build.gradle.kts | 2 +- nextjs-engine/build.gradle.kts | 1 + .../kdriver/nextjs/AbstractCapturedPushes.kt | 1 + .../kdriver/nextjs/FlightPayloadResolver.kt | 113 ----- nextjs-rsc/build.gradle.kts | 104 +++++ .../nextjs/rsc/FlightPayloadResolver.kt | 223 ++++++++++ .../dev/kdriver/nextjs/rsc/ParsedRow.kt | 10 + .../kdriver/nextjs/rsc/ReferenceResolver.kt | 291 +++++++++++++ .../dev/kdriver/nextjs/rsc/RowParser.kt | 83 ++++ .../kotlin/dev/kdriver/nextjs/rsc/RowValue.kt | 54 +++ .../dev/kdriver/nextjs/rsc/TagHandler.kt | 181 ++++++++ .../nextjs/rsc/FlightPayloadResolverTest.kt | 403 ++++++++++++++++++ .../nextjs/rsc/ReferenceResolverTest.kt | 205 +++++++++ .../dev/kdriver/nextjs/rsc/RowParserTest.kt | 131 ++++++ settings.gradle.kts | 1 + 16 files changed, 1690 insertions(+), 115 deletions(-) delete mode 100644 nextjs-engine/src/commonMain/kotlin/dev/kdriver/nextjs/FlightPayloadResolver.kt create mode 100644 nextjs-rsc/build.gradle.kts create mode 100644 nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/FlightPayloadResolver.kt create mode 100644 nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/ParsedRow.kt create mode 100644 nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/ReferenceResolver.kt create mode 100644 nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/RowParser.kt create mode 100644 nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/RowValue.kt create mode 100644 nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/TagHandler.kt create mode 100644 nextjs-rsc/src/jvmTest/kotlin/dev/kdriver/nextjs/rsc/FlightPayloadResolverTest.kt create mode 100644 nextjs-rsc/src/jvmTest/kotlin/dev/kdriver/nextjs/rsc/ReferenceResolverTest.kt create mode 100644 nextjs-rsc/src/jvmTest/kotlin/dev/kdriver/nextjs/rsc/RowParserTest.kt diff --git a/README.md b/README.md index b2c14e8..62ac360 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Add the dependency to your `build.gradle.kts`: ```kotlin dependencies { - implementation("dev.kdriver:nextjs:0.1.5") + implementation("dev.kdriver:nextjs:0.2.0") } ``` diff --git a/build.gradle.kts b/build.gradle.kts index 36e4972..eb045bc 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,7 +6,7 @@ plugins { allprojects { group = "dev.kdriver" - version = "0.1.5" + version = "0.2.0" 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") diff --git a/nextjs-engine/build.gradle.kts b/nextjs-engine/build.gradle.kts index 344fd90..0d8c570 100644 --- a/nextjs-engine/build.gradle.kts +++ b/nextjs-engine/build.gradle.kts @@ -86,6 +86,7 @@ kotlin { val commonMain by getting { dependencies { api(libs.kotlinx.serialization.json) + api(project(":nextjs-rsc")) } } val jvmTest by getting { diff --git a/nextjs-engine/src/commonMain/kotlin/dev/kdriver/nextjs/AbstractCapturedPushes.kt b/nextjs-engine/src/commonMain/kotlin/dev/kdriver/nextjs/AbstractCapturedPushes.kt index d8f62a0..088d17b 100644 --- a/nextjs-engine/src/commonMain/kotlin/dev/kdriver/nextjs/AbstractCapturedPushes.kt +++ b/nextjs-engine/src/commonMain/kotlin/dev/kdriver/nextjs/AbstractCapturedPushes.kt @@ -1,5 +1,6 @@ package dev.kdriver.nextjs +import dev.kdriver.nextjs.rsc.FlightPayloadResolver import kotlinx.serialization.json.* /** diff --git a/nextjs-engine/src/commonMain/kotlin/dev/kdriver/nextjs/FlightPayloadResolver.kt b/nextjs-engine/src/commonMain/kotlin/dev/kdriver/nextjs/FlightPayloadResolver.kt deleted file mode 100644 index 2fdbd05..0000000 --- a/nextjs-engine/src/commonMain/kotlin/dev/kdriver/nextjs/FlightPayloadResolver.kt +++ /dev/null @@ -1,113 +0,0 @@ -package dev.kdriver.nextjs - -import kotlinx.serialization.json.* - -class FlightPayloadResolver { - - // Map of row ID -> parsed content - private val rows = mutableMapOf() - - /** - * Parse all the __next_f pushes and build the row map - */ - fun parsePayloads(pushes: JsonArray) { - for (push in pushes) { - val arr = push.jsonArray - val type = arr[0].jsonPrimitive.int - - if (type == 1) { - // Type 1 = row data as string - val payload = arr[1].jsonPrimitive.content - parseRowData(payload) - } - } - } - - private fun parseRowData(payload: String) { - // Each payload can contain multiple lines - for (line in payload.split("\n")) { - if (line.isBlank()) continue - - // Format: id:typeCode:jsonData OR id:jsonData - val colonIndex = line.indexOf(':') - if (colonIndex == -1) continue - - val rowId = line.substring(0, colonIndex) - val rest = line.substring(colonIndex + 1) - - // Check for type code (I, HL, etc.) - val secondColon = rest.indexOf(':') - val jsonData = if (secondColon != -1 && secondColon < 3) { - // Has type code like "I:" or "HL:" - rest.substring(secondColon + 1) - } else { - rest - } - - if (jsonData.isNotBlank()) { - try { - rows[rowId] = Json.parseToJsonElement(jsonData) - } catch (e: Exception) { - // Some rows contain non-JSON data - } - } - } - } - - /** - * Resolve all references in an element recursively - */ - fun resolve(element: JsonElement): JsonElement { - return when (element) { - is JsonPrimitive -> resolveReference(element) - is JsonArray -> JsonArray(element.map { resolve(it) }) - is JsonObject -> JsonObject(element.mapValues { resolve(it.value) }) - else -> element - } - } - - private fun resolveReference(primitive: JsonPrimitive): JsonElement { - if (!primitive.isString) return primitive - - val value = primitive.content - if (!value.startsWith("$")) return primitive - - return when { - // Special literals - value == "\$undefined" -> JsonNull - value == "\$Infinity" -> JsonPrimitive(Double.POSITIVE_INFINITY) - value == "\$-Infinity" -> JsonPrimitive(Double.NEGATIVE_INFINITY) - value == "\$NaN" -> JsonPrimitive(Double.NaN) - - // Lazy reference: $Lxx - value.startsWith("\$L") -> { - val rowId = value.substring(2) - rows[rowId]?.let { resolve(it) } ?: primitive - } - - // Promise reference: $@xx - value.startsWith("\$@") -> { - val rowId = value.substring(2) - rows[rowId]?.let { resolve(it) } ?: primitive - } - - // Server function: $Fxx (usually keep as-is or handle specially) - value.startsWith("\$F") -> primitive - - // Direct reference: $xx (just digits after $) - value.matches(Regex("^\\\$[0-9a-fA-F]+$")) -> { - val rowId = value.substring(1) - rows[rowId]?.let { resolve(it) } ?: primitive - } - - else -> primitive - } - } - - /** - * Get the fully resolved root element - */ - fun getResolvedRoot(): JsonElement? { - return rows["0"]?.let { resolve(it) } - } -} diff --git a/nextjs-rsc/build.gradle.kts b/nextjs-rsc/build.gradle.kts new file mode 100644 index 0000000..2ff8e77 --- /dev/null +++ b/nextjs-rsc/build.gradle.kts @@ -0,0 +1,104 @@ +plugins { + alias(libs.plugins.multiplatform) + alias(libs.plugins.serialization) + alias(libs.plugins.kover) + alias(libs.plugins.detekt) + alias(libs.plugins.dokka) + alias(libs.plugins.ksp) + alias(libs.plugins.maven) +} + +mavenPublishing { + publishToMavenCentral(com.vanniktech.maven.publish.SonatypeHost.CENTRAL_PORTAL) + signAllPublications() + pom { + name.set("nextjs-rsc") + description.set("React Server Components (RSC) payload parser for kdriver.") + url.set(project.ext.get("url")?.toString()) + licenses { + license { + name.set(project.ext.get("license.name")?.toString()) + url.set(project.ext.get("license.url")?.toString()) + } + } + developers { + developer { + id.set(project.ext.get("developer.id")?.toString()) + name.set(project.ext.get("developer.name")?.toString()) + email.set(project.ext.get("developer.email")?.toString()) + url.set(project.ext.get("developer.url")?.toString()) + } + } + scm { + url.set(project.ext.get("scm.url")?.toString()) + } + } +} + +kotlin { + // Tiers are in accordance with + // Tier 1 + macosX64() + macosArm64() + iosSimulatorArm64() + iosX64() + + // Tier 2 + linuxX64() + linuxArm64() + watchosSimulatorArm64() + watchosX64() + watchosArm32() + watchosArm64() + tvosSimulatorArm64() + tvosX64() + tvosArm64() + iosArm64() + + // Tier 3 + mingwX64() + watchosDeviceArm64() + + // jvm & js + jvmToolchain(21) + jvm { + testRuns.named("test") { + executionTask.configure { + useJUnitPlatform() + } + } + } + js { + generateTypeScriptDefinitions() + binaries.library() + nodejs() + browser() + } + + applyDefaultHierarchyTemplate() + sourceSets { + all { + languageSettings.apply { + optIn("kotlin.js.ExperimentalJsExport") + } + } + val commonMain by getting { + dependencies { + api(libs.kotlinx.serialization.json) + } + } + val jvmTest by getting { + dependencies { + implementation(kotlin("test")) + implementation(libs.tests.mockk) + implementation(libs.tests.coroutines) + } + } + } +} + +detekt { + buildUponDefaultConfig = true + config.setFrom("${rootProject.projectDir}/detekt.yml") + source.from(file("src/commonMain/kotlin")) +} diff --git a/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/FlightPayloadResolver.kt b/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/FlightPayloadResolver.kt new file mode 100644 index 0000000..3e208f0 --- /dev/null +++ b/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/FlightPayloadResolver.kt @@ -0,0 +1,223 @@ +package dev.kdriver.nextjs.rsc + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonPrimitive + +/** + * Resolves React Server Components (RSC) Flight payloads. + * + * This resolver parses RSC row format, handles various tag types, + * and resolves all references (especially $L lazy references). + * + * ## Usage + * + * ```kotlin + * val resolver = FlightPayloadResolver() + * resolver.parsePayloads(nextFPushes) + * val resolved = resolver.getResolvedRoot() + * ``` + * + * ## Architecture + * + * The resolver is designed to be extensible: + * - Add new tag handlers by implementing [TagHandler] and registering them + * - Add new reference types by extending [ReferenceResolver] + * - Parse custom row formats by customizing [RowParser] + * + * ## Main Use Case: $L References + * + * The primary purpose is to resolve `$L` (lazy) references which point + * to components that should be lazily loaded. These are recursively + * resolved to build the complete component tree. + */ +class FlightPayloadResolver( + /** + * Custom tag handler registry. If not provided, uses default handlers. + */ + private val tagRegistry: TagHandlerRegistry = TagHandlerRegistry(), + + /** + * Maximum recursion depth for reference resolution. + * Prevents infinite loops in circular references. + */ + private val maxDepth: Int = 100 +) { + + /** + * Map of row ID to parsed row value. + */ + private val rows = mutableMapOf() + + /** + * Parse all __next_f pushes and build the row map. + * + * Expected format: + * ```json + * [ + * [1, "0:\"$L1\"\n"], + * [1, "1:I[\"path\",[]]\n"], + * [1, "2:[\"$\",\"div\",null,{}]\n"] + * ] + * ``` + * + * @param pushes JsonArray of push events from __next_f + */ + fun parsePayloads(pushes: JsonArray) { + for (push in pushes) { + val arr = push.jsonArray + if (arr.isEmpty()) continue + + val type = arr[0].jsonPrimitive.content.toIntOrNull() ?: continue + + // Type 1 = row data as string + if (type == 1 && arr.size >= 2) { + val payload = arr[1].jsonPrimitive.content + parsePayload(payload) + } + } + } + + /** + * Parse a single payload string containing one or more rows. + * + * @param payload The payload string with newline-separated rows + */ + private fun parsePayload(payload: String) { + val parsedRows = RowParser.parseRows(payload) + + for (row in parsedRows) { + val rowValue = parseRowValue(row) + rows[row.id] = rowValue + } + } + + /** + * Parse a single row into a [RowValue]. + */ + private fun parseRowValue(row: ParsedRow): RowValue { + return when { + // Has a tag - use tag handler + row.tag != null -> tagRegistry.parse(row.tag, row.data) + + // No tag - assume JSON model + else -> { + try { + val json = Json.parseToJsonElement(row.data) + RowValue.Model(json) + } catch (e: Exception) { + // Failed to parse JSON, store as unknown + RowValue.Unknown('?', row.data) + } + } + } + } + + /** + * Resolve all references in an element recursively. + * + * This is the main method for resolving `$L` and other references. + * + * @param element The element to resolve + * @return The fully resolved element + */ + fun resolve(element: JsonElement): JsonElement { + val resolver = ReferenceResolver(rows, maxDepth) + return resolver.resolve(element) + } + + /** + * Get the fully resolved root element (row 0). + * + * This is typically what you want to render as it contains + * the complete component tree with all `$L` references resolved. + * + * @return The resolved root element, or null if row 0 doesn't exist + */ + fun getResolvedRoot(): JsonElement? { + val rootValue = rows["0"] ?: return null + + // Convert root value to JSON + val rootJson = when (rootValue) { + is RowValue.Model -> rootValue.json + is RowValue.Text -> kotlinx.serialization.json.JsonPrimitive(rootValue.value) + is RowValue.Module -> kotlinx.serialization.json.JsonPrimitive( + "[Module: ${rootValue.path}]" + ) + is RowValue.Error -> kotlinx.serialization.json.JsonPrimitive( + "[Error: ${rootValue.message}]" + ) + is RowValue.Hint -> kotlinx.serialization.json.JsonPrimitive( + "[Hint: ${rootValue.code}]" + ) + is RowValue.DebugInfo -> rootValue.data + is RowValue.Unknown -> kotlinx.serialization.json.JsonPrimitive( + "[Unknown: ${rootValue.tag}]" + ) + } + + // Resolve all references + return resolve(rootJson) + } + + /** + * Get a specific row by ID (resolved). + * + * @param rowId The row ID to retrieve + * @return The resolved row value, or null if not found + */ + fun getResolvedRow(rowId: String): JsonElement? { + val rowValue = rows[rowId] ?: return null + + val json = when (rowValue) { + is RowValue.Model -> rowValue.json + is RowValue.Text -> kotlinx.serialization.json.JsonPrimitive(rowValue.value) + else -> return null + } + + return resolve(json) + } + + /** + * Get all parsed rows (unresolved). + * + * Useful for debugging or inspecting the raw parsed data. + * + * @return Map of row ID to RowValue + */ + fun getAllRows(): Map = rows.toMap() + + /** + * Register a custom tag handler. + * + * This allows extending the resolver with new tag types. + * + * Example: + * ```kotlin + * class CustomTagHandler : TagHandler { + * override val tag: Char = 'X' + * override fun parse(data: String): RowValue { + * // Custom parsing logic + * } + * } + * + * resolver.registerTagHandler(CustomTagHandler()) + * ``` + * + * @param handler The tag handler to register + */ + fun registerTagHandler(handler: TagHandler) { + tagRegistry.register(handler) + } + + /** + * Clear all parsed rows. + * + * Useful for reusing the resolver instance. + */ + fun clear() { + rows.clear() + } +} diff --git a/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/ParsedRow.kt b/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/ParsedRow.kt new file mode 100644 index 0000000..01a4d33 --- /dev/null +++ b/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/ParsedRow.kt @@ -0,0 +1,10 @@ +package dev.kdriver.nextjs.rsc + +/** + * Represents a parsed RSC row with its ID, optional tag, and data. + */ +data class ParsedRow( + val id: String, + val tag: Char?, + val data: String, +) diff --git a/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/ReferenceResolver.kt b/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/ReferenceResolver.kt new file mode 100644 index 0000000..9788d87 --- /dev/null +++ b/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/ReferenceResolver.kt @@ -0,0 +1,291 @@ +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 + +/** + * Resolves RSC references in JSON elements. + * + * References are strings starting with `$` followed by a type indicator: + * - `$Lxx` - Lazy reference (main use case!) + * - `$@xx` - Promise reference + * - `$Fxx` - Server function reference + * - `$Qxx` - Map reference + * - `$Wxx` - Set reference + * - `$Bxx` - Blob reference + * - `$Kxx` - FormData reference + * - `$Zxx` - Error reference + * - `$ixx` - Iterator reference + * - `$xx` - Direct reference (hex digits only) + * - `$$` - Escaped dollar sign + * - Special literals: `$undefined`, `$Infinity`, `$-Infinity`, `$NaN`, `$-0` + * - Typed values: `$D...` (Date), `$n...` (BigInt), `$S...` (Symbol) + */ +class ReferenceResolver( + private val rows: Map, + private val maxDepth: Int = 100 +) { + + /** + * Resolve all references in an element recursively. + * + * @param element The element to resolve + * @param depth Current recursion depth (for cycle detection) + * @return The resolved element + */ + fun resolve(element: JsonElement, depth: Int = 0): JsonElement { + // Prevent infinite recursion + if (depth > maxDepth) { + return JsonPrimitive("[Max depth exceeded]") + } + + return when (element) { + 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 + } + } + + /** + * Resolve a primitive value that might be a reference. + */ + private fun resolveReference(primitive: JsonPrimitive, depth: Int): JsonElement { + if (!primitive.isString) return primitive + + val value = primitive.content + if (!value.startsWith("$")) return primitive + + // Handle two-character prefixes first + if (value.length >= 2) { + when (value.substring(0, 2)) { + // Escaped dollar sign + "$$" -> return JsonPrimitive(value.substring(1)) + + // Lazy reference - MAIN USE CASE! + "\$L" -> return resolveLazyReference(value.substring(2), depth) + + // Promise reference + "\$@" -> return resolvePromiseReference(value.substring(2), depth) + + // Server function reference + "\$F" -> return resolveServerFunctionReference(value.substring(2), depth) + + // Map reference + "\$Q" -> return resolveMapReference(value.substring(2), depth) + + // Set reference + "\$W" -> return resolveSetReference(value.substring(2), depth) + + // Blob reference + "\$B" -> return resolveBlobReference(value.substring(2), depth) + + // FormData reference + "\$K" -> return resolveFormDataReference(value.substring(2), depth) + + // Error reference + "\$Z" -> return resolveErrorReference(value.substring(2), depth) + + // Iterator reference + "\$i" -> return resolveIteratorReference(value.substring(2), depth) + + // Date + "\$D" -> return resolveDateReference(value.substring(2)) + + // BigInt + "\$n" -> return resolveBigIntReference(value.substring(2)) + + // Symbol + "\$S" -> return resolveSymbolReference(value.substring(2)) + } + } + + // Handle special literals + return when (value) { + "\$undefined" -> JsonNull + "\$Infinity" -> JsonPrimitive(Double.POSITIVE_INFINITY) + "\$-Infinity" -> JsonPrimitive(Double.NEGATIVE_INFINITY) + "\$NaN" -> JsonPrimitive(Double.NaN) + "\$-0" -> JsonPrimitive(-0.0) + else -> { + // Check if it's a direct hex reference: $xx + if (value.matches(Regex("^\\$[0-9a-fA-F]+$"))) { + resolveDirectReference(value.substring(1), depth) + } else { + // Unknown reference type, keep as-is + primitive + } + } + } + } + + /** + * Resolve a lazy reference ($Lxx) - MAIN USE CASE! + * + * Lazy references are the most common type and point to components + * that should be lazily loaded/rendered. + */ + private fun resolveLazyReference(rowId: String, depth: Int): JsonElement { + val rowValue = rows[rowId] + if (rowValue == null) { + // Row not found, return placeholder + return JsonPrimitive("\$L$rowId [not found]") + } + + // Convert row value to JSON and resolve recursively + val jsonElement = rowValueToJson(rowValue) + return resolve(jsonElement, depth + 1) + } + + /** + * Resolve a promise reference ($@xx). + */ + private fun resolvePromiseReference(rowId: String, depth: Int): JsonElement { + val rowValue = rows[rowId] + if (rowValue == null) { + return JsonPrimitive("\$@$rowId [not found]") + } + + val jsonElement = rowValueToJson(rowValue) + return resolve(jsonElement, depth + 1) + } + + /** + * Resolve a server function reference ($Fxx). + * + * Server functions are usually kept as-is or marked as functions. + */ + private fun resolveServerFunctionReference(ref: String, depth: Int): JsonElement { + // Parse ref which can be "rowId" or "rowId:path:to:field" + val parts = ref.split(':') + val rowId = parts[0] + + val rowValue = rows[rowId] + if (rowValue == null) { + return JsonPrimitive("[Server Function: \$F$ref]") + } + + // If there's a path, we'd need to traverse it + // For now, just return a marker + return JsonPrimitive("[Server Function: \$F$ref]") + } + + /** + * Resolve a Map reference ($Qxx). + */ + private fun resolveMapReference(rowId: String, depth: Int): JsonElement { + val rowValue = rows[rowId] + if (rowValue == null) { + return JsonPrimitive("[Map: \$Q$rowId not found]") + } + + val jsonElement = rowValueToJson(rowValue) + return resolve(jsonElement, depth + 1) + } + + /** + * Resolve a Set reference ($Wxx). + */ + private fun resolveSetReference(rowId: String, depth: Int): JsonElement { + val rowValue = rows[rowId] + if (rowValue == null) { + return JsonPrimitive("[Set: \$W$rowId not found]") + } + + val jsonElement = rowValueToJson(rowValue) + return resolve(jsonElement, depth + 1) + } + + /** + * Resolve a Blob reference ($Bxx). + */ + private fun resolveBlobReference(rowId: String, depth: Int): JsonElement { + return JsonPrimitive("[Blob: \$B$rowId]") + } + + /** + * Resolve a FormData reference ($Kxx). + */ + private fun resolveFormDataReference(rowId: String, depth: Int): JsonElement { + return JsonPrimitive("[FormData: \$K$rowId]") + } + + /** + * Resolve an Error reference ($Zxx). + */ + private fun resolveErrorReference(rowId: String, depth: Int): JsonElement { + val rowValue = rows[rowId] + if (rowValue == null) { + return JsonPrimitive("[Error: \$Z$rowId not found]") + } + + return when (rowValue) { + is RowValue.Error -> JsonPrimitive("[Error: ${rowValue.message}]") + else -> { + val jsonElement = rowValueToJson(rowValue) + resolve(jsonElement, depth + 1) + } + } + } + + /** + * Resolve an Iterator reference ($ixx). + */ + private fun resolveIteratorReference(rowId: String, depth: Int): JsonElement { + return JsonPrimitive("[Iterator: \$i$rowId]") + } + + /** + * Resolve a Date reference ($D...). + */ + private fun resolveDateReference(isoString: String): JsonElement { + // Store as ISO string in JSON + return JsonPrimitive("[Date: $isoString]") + } + + /** + * Resolve a BigInt reference ($n...). + */ + private fun resolveBigIntReference(digits: String): JsonElement { + // Store as string since JSON doesn't support BigInt + return JsonPrimitive("[BigInt: $digits]") + } + + /** + * Resolve a Symbol reference ($S...). + */ + private fun resolveSymbolReference(description: String): JsonElement { + return JsonPrimitive("[Symbol: $description]") + } + + /** + * Resolve a direct reference ($xx where xx is hex). + */ + private fun resolveDirectReference(rowId: String, depth: Int): JsonElement { + val rowValue = rows[rowId] + if (rowValue == null) { + return JsonPrimitive("\$$rowId [not found]") + } + + val jsonElement = rowValueToJson(rowValue) + return resolve(jsonElement, depth + 1) + } + + /** + * Convert a RowValue to JsonElement for resolution. + */ + private fun rowValueToJson(rowValue: RowValue): JsonElement { + return when (rowValue) { + is RowValue.Model -> rowValue.json + is RowValue.Text -> JsonPrimitive(rowValue.value) + is RowValue.Module -> JsonPrimitive("[Module: ${rowValue.path}]") + is RowValue.Error -> JsonPrimitive("[Error: ${rowValue.message}]") + is RowValue.Hint -> JsonPrimitive("[Hint: ${rowValue.code}]") + is RowValue.DebugInfo -> rowValue.data + is RowValue.Unknown -> JsonPrimitive("[Unknown: ${rowValue.tag}]") + } + } +} diff --git a/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/RowParser.kt b/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/RowParser.kt new file mode 100644 index 0000000..91c3b15 --- /dev/null +++ b/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/RowParser.kt @@ -0,0 +1,83 @@ +package dev.kdriver.nextjs.rsc + +/** + * Parses RSC row format: `id:tag:data` or `id:data` (no tag). + * + * Examples: + * - `0:"$L1"` → ParsedRow(id="0", tag=null, data="\"$L1\"") + * - `1:I["path",["exports"],"name"]` → ParsedRow(id="1", tag='I', data="[\"path\",...]") + * - `2:H:{"key":"value"}` → ParsedRow(id="2", tag='H', data="{\"key\":...}") + */ +object RowParser { + + /** + * Parses a single row line into its components. + * + * @param line The row line to parse + * @return A [ParsedRow] with id, tag (if present), and data, or null if invalid + */ + fun parseRow(line: String): ParsedRow? { + if (line.isBlank()) return null + + // Find first colon (separates ID from rest) + val firstColonIndex = line.indexOf(':') + if (firstColonIndex == -1) return null + + val id = line.substring(0, firstColonIndex) + val rest = line.substring(firstColonIndex + 1) + + // Check if there's a tag (single letter followed by colon or data) + val (tag, data) = extractTagAndData(rest) + + return ParsedRow(id, tag, data) + } + + /** + * Extracts the tag (if present) and data from the rest of the row. + * + * Format can be: + * - `I:data` → tag='I', data="data" + * - `I[...]` → tag='I', data="[...]" + * - `data` → tag=null, data="data" + */ + private fun extractTagAndData(rest: String): Pair { + if (rest.isEmpty()) return null to "" + + // Check if first character is an UPPERCASE letter (potential tag) + val firstChar = rest[0] + if (!firstChar.isUpperCase()) { + // No tag, entire rest is data + return null to rest + } + + // Check if it's a tag by looking at what follows + if (rest.length == 1) { + // Just a letter, treat as tag with empty data + return firstChar to "" + } + + val secondChar = rest[1] + return when { + // Pattern: `T:data` → tag with colon separator + secondChar == ':' -> firstChar to rest.substring(2) + + // Pattern: `Testing` → lowercase after uppercase means it's a word, not a tag + secondChar.isLowerCase() -> null to rest + + // Pattern: `I[...]` or `TText` (uppercase) → tag directly followed by data + // Tags are single uppercase letters followed by non-lowercase chars + else -> firstChar to rest.substring(1) + } + } + + /** + * Parses multiple row lines from a payload. + * + * @param payload The complete payload string with newline-separated rows + * @return List of successfully parsed rows + */ + fun parseRows(payload: String): List { + return payload.split('\n') + .mapNotNull { parseRow(it) } + } +} diff --git a/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/RowValue.kt b/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/RowValue.kt new file mode 100644 index 0000000..27bdad6 --- /dev/null +++ b/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/RowValue.kt @@ -0,0 +1,54 @@ +package dev.kdriver.nextjs.rsc + +import kotlinx.serialization.json.JsonElement + +/** + * Represents the different types of row values that can be stored. + */ +sealed class RowValue { + /** + * JSON model data (React elements, objects, arrays). + */ + data class Model(val json: JsonElement) : RowValue() + + /** + * Module/import metadata. + */ + data class Module( + val path: String, + val exports: List, + val name: String, + ) : RowValue() + + /** + * Hint for preload/prefetch (fonts, stylesheets, etc.). + */ + data class Hint( + val code: String, + val model: JsonElement, + ) : RowValue() + + /** + * Error object. + */ + data class Error( + val message: String, + val stack: String?, + val digest: String?, + ) : RowValue() + + /** + * Large text string. + */ + data class Text(val value: String) : RowValue() + + /** + * Debug information. + */ + data class DebugInfo(val data: JsonElement) : RowValue() + + /** + * Unknown or unsupported tag type. + */ + data class Unknown(val tag: Char, val rawData: String) : RowValue() +} diff --git a/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/TagHandler.kt b/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/TagHandler.kt new file mode 100644 index 0000000..2833256 --- /dev/null +++ b/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/TagHandler.kt @@ -0,0 +1,181 @@ +package dev.kdriver.nextjs.rsc + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +/** + * Interface for handling specific RSC row tags. + */ +interface TagHandler { + /** + * The tag character this handler processes. + */ + val tag: Char + + /** + * Parse the row data into a [RowValue]. + * + * @param data The data portion of the row (after tag) + * @return A [RowValue] representing the parsed data + */ + fun parse(data: String): RowValue +} + +/** + * Registry for tag handlers. Allows easy addition of new handlers. + */ +class TagHandlerRegistry { + private val handlers = mutableMapOf() + + init { + // Register built-in handlers + register(ModuleTagHandler()) + register(HintTagHandler()) + register(ErrorTagHandler()) + register(TextTagHandler()) + register(DebugInfoTagHandler()) + } + + /** + * Register a new tag handler. + */ + fun register(handler: TagHandler) { + handlers[handler.tag] = handler + } + + /** + * Get handler for a specific tag. + */ + fun getHandler(tag: Char): TagHandler? = handlers[tag] + + /** + * Parse data using the appropriate handler. + */ + fun parse(tag: Char, data: String): RowValue { + return handlers[tag]?.parse(data) + ?: RowValue.Unknown(tag, data) + } +} + +/** + * Handler for module imports (tag: I). + * + * Format: ["path", ["export1", "export2"], "name"] + * Example: I["(app)/page", ["default"], ""] + */ +class ModuleTagHandler : TagHandler { + override val tag: Char = 'I' + + override fun parse(data: String): RowValue { + return try { + val json = Json.parseToJsonElement(data).jsonArray + RowValue.Module( + path = json[0].jsonPrimitive.content, + exports = json[1].jsonArray.map { it.jsonPrimitive.content }, + name = json.getOrNull(2)?.jsonPrimitive?.content ?: "" + ) + } catch (e: Exception) { + RowValue.Unknown(tag, data) + } + } +} + +/** + * Handler for hints (tag: H). + * + * Format: code:data or codedata + * Example: H["font", "https://fonts.googleapis.com/..."] + */ +class HintTagHandler : TagHandler { + override val tag: Char = 'H' + + override fun parse(data: String): RowValue { + return try { + // Hints can be in format: code + JSON array + // e.g., "H" + ["font", "url"] + val json = Json.parseToJsonElement(data).jsonArray + val code = json.getOrNull(0)?.jsonPrimitive?.content ?: "unknown" + RowValue.Hint(code, json) + } catch (e: Exception) { + // Fallback: treat first char as code + if (data.isNotEmpty()) { + val code = data[0].toString() + val rest = if (data.length > 1) data.substring(1) else "{}" + try { + val json = Json.parseToJsonElement(rest) + RowValue.Hint(code, json) + } catch (e2: Exception) { + RowValue.Unknown(tag, data) + } + } else { + RowValue.Unknown(tag, data) + } + } + } +} + +/** + * Handler for errors (tag: E). + * + * Format: {"message": "...", "stack": "...", "digest": "..."} + * Example: E{"message": "Not found", "stack": "..."} + */ +class ErrorTagHandler : TagHandler { + override val tag: Char = 'E' + + override fun parse(data: String): RowValue { + return try { + val json = Json.parseToJsonElement(data).jsonObject + RowValue.Error( + message = json["message"]?.jsonPrimitive?.content + ?: "Unknown error", + stack = json["stack"]?.jsonPrimitive?.content, + digest = json["digest"]?.jsonPrimitive?.content + ) + } catch (e: Exception) { + RowValue.Error( + message = "Failed to parse error: ${e.message}", + stack = null, + digest = null + ) + } + } +} + +/** + * Handler for text chunks (tag: T). + * + * Format: plain text or with length encoding + * Example: T:10,Hello World + */ +class TextTagHandler : TagHandler { + override val tag: Char = 'T' + + override fun parse(data: String): RowValue { + // For now, treat entire data as text + // TODO: Handle length-based encoding if needed + return RowValue.Text(data) + } +} + +/** + * Handler for debug info (tag: D). + * + * Format: JSON debug metadata + * Example: D{"name": "Component", "stack": "..."} + */ +class DebugInfoTagHandler : TagHandler { + override val tag: Char = 'D' + + override fun parse(data: String): RowValue { + return try { + val json = Json.parseToJsonElement(data) + RowValue.DebugInfo(json) + } catch (e: Exception) { + RowValue.Unknown(tag, data) + } + } +} diff --git a/nextjs-rsc/src/jvmTest/kotlin/dev/kdriver/nextjs/rsc/FlightPayloadResolverTest.kt b/nextjs-rsc/src/jvmTest/kotlin/dev/kdriver/nextjs/rsc/FlightPayloadResolverTest.kt new file mode 100644 index 0000000..1456d04 --- /dev/null +++ b/nextjs-rsc/src/jvmTest/kotlin/dev/kdriver/nextjs/rsc/FlightPayloadResolverTest.kt @@ -0,0 +1,403 @@ +package dev.kdriver.nextjs.rsc + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull + +class FlightPayloadResolverTest { + + /** + * Test basic $L (lazy) reference resolution. + * This is the MAIN USE CASE! + */ + @Test + fun `test basic lazy reference resolution`() { + val payload = """ + 0:"${'$'}L1" + 1:["${'$'}","p",null,{"children":"Hello World"}] + """.trimIndent() + + val resolver = FlightPayloadResolver() + resolver.parsePayloads(createPushesArray(payload)) + + val result = resolver.getResolvedRoot() + assertNotNull(result) + + // Should resolve to the array in row 1 + assertIs(result) + assertEquals("$", result[0].jsonPrimitive.content) + assertEquals("p", result[1].jsonPrimitive.content) + } + + /** + * Test nested $L references (component tree). + */ + @Test + fun `test nested lazy references`() { + val payload = """ + 0:"${'$'}L1" + 1:["${'$'}","div",null,{"className":"container","children":"${'$'}L2"}] + 2:["${'$'}","p",null,{"children":"Hello World"}] + """.trimIndent() + + val resolver = FlightPayloadResolver() + resolver.parsePayloads(createPushesArray(payload)) + + val result = resolver.getResolvedRoot() + assertNotNull(result) + + // Root should be the div + assertIs(result) + assertEquals("div", result[1].jsonPrimitive.content) + + // Children should be resolved + val props = result[3].jsonObject + val children = props["children"] + assertNotNull(children) + assertIs(children) + assertEquals("p", children[1].jsonPrimitive.content) + } + + /** + * Test multiple $L references in the same element. + */ + @Test + fun `test multiple lazy references`() { + val payload = """ + 0:"${'$'}L1" + 1:["${'$'}","html",null,{"children":["${'$'}L2","${'$'}L3"]}] + 2:["${'$'}","head",null,{}] + 3:["${'$'}","body",null,{"children":"Content"}] + """.trimIndent() + + val resolver = FlightPayloadResolver() + resolver.parsePayloads(createPushesArray(payload)) + + val result = resolver.getResolvedRoot() + assertNotNull(result) + + val props = result.jsonArray[3].jsonObject + val children = props["children"]?.jsonArray + assertNotNull(children) + assertEquals(2, children.size) + + // Both children should be resolved arrays + assertIs(children[0]) + assertIs(children[1]) + } + + /** + * Test $L reference with module import. + */ + @Test + fun `test lazy reference to module`() { + val payload = """ + 0:"${'$'}L1" + 1:I["(app)/page",["default"],""] + """.trimIndent() + + val resolver = FlightPayloadResolver() + resolver.parsePayloads(createPushesArray(payload)) + + val result = resolver.getResolvedRoot() + assertNotNull(result) + + // Should resolve to module placeholder + assertIs(result) + assert(result.content.contains("Module")) + } + + /** + * Test direct hex reference ($xx). + */ + @Test + fun `test direct hex reference`() { + val payload = """ + 0:"${'$'}2" + 2:["${'$'}","div",null,{"children":"Direct ref"}] + """.trimIndent() + + val resolver = FlightPayloadResolver() + resolver.parsePayloads(createPushesArray(payload)) + + val result = resolver.getResolvedRoot() + assertNotNull(result) + + assertIs(result) + assertEquals("div", result[1].jsonPrimitive.content) + } + + /** + * Test promise reference ($@). + */ + @Test + fun `test promise reference`() { + val payload = """ + 0:"${'$'}@1" + 1:["${'$'}","div",null,{"children":"Async content"}] + """.trimIndent() + + val resolver = FlightPayloadResolver() + resolver.parsePayloads(createPushesArray(payload)) + + val result = resolver.getResolvedRoot() + assertNotNull(result) + + assertIs(result) + assertEquals("div", result[1].jsonPrimitive.content) + } + + /** + * Test escaped dollar sign ($$). + */ + @Test + fun `test escaped dollar sign`() { + val payload = """ + 0:{"price":"${'$'}${'$'}100"} + """.trimIndent() + + val resolver = FlightPayloadResolver() + resolver.parsePayloads(createPushesArray(payload)) + + val result = resolver.getResolvedRoot() + assertNotNull(result) + + val obj = result.jsonObject + assertEquals("$100", obj["price"]?.jsonPrimitive?.content) + } + + /** + * Test special literals. + */ + @Test + fun `test special literals`() { + val payload = """ + 0:{"undef":"${'$'}undefined","inf":"${'$'}Infinity","negInf":"${'$'}-Infinity","nan":"${'$'}NaN"} + """.trimIndent() + + val resolver = FlightPayloadResolver() + resolver.parsePayloads(createPushesArray(payload)) + + val result = resolver.getResolvedRoot() + assertNotNull(result) + + val obj = result.jsonObject + // $undefined becomes null in JSON + assertIs(obj["undef"]) + } + + /** + * Test module import (tag I). + */ + @Test + fun `test module import tag`() { + val payload = """ + 0:"${'$'}L1" + 1:I["(app)/components/Button",["default","Secondary"],"Button"] + """.trimIndent() + + val resolver = FlightPayloadResolver() + resolver.parsePayloads(createPushesArray(payload)) + + val rows = resolver.getAllRows() + val moduleRow = rows["1"] + + assertNotNull(moduleRow) + assertIs(moduleRow) + assertEquals("(app)/components/Button", moduleRow.path) + assertEquals(listOf("default", "Secondary"), moduleRow.exports) + assertEquals("Button", moduleRow.name) + } + + /** + * Test error tag (E). + */ + @Test + fun `test error tag`() { + val payload = """ + 0:"${'$'}L1" + 1:E{"message":"Not found","stack":"Error: Not found\n at ...","digest":"abc123"} + """.trimIndent() + + val resolver = FlightPayloadResolver() + resolver.parsePayloads(createPushesArray(payload)) + + val rows = resolver.getAllRows() + val errorRow = rows["1"] + + assertNotNull(errorRow) + assertIs(errorRow) + assertEquals("Not found", errorRow.message) + assertNotNull(errorRow.stack) + assertEquals("abc123", errorRow.digest) + } + + /** + * Test text tag (T). + */ + @Test + fun `test text tag`() { + val payload = """ + 0:"${'$'}L1" + 1:TLarge text content here + """.trimIndent() + + val resolver = FlightPayloadResolver() + resolver.parsePayloads(createPushesArray(payload)) + + val rows = resolver.getAllRows() + val textRow = rows["1"] + + assertNotNull(textRow) + assertIs(textRow) + assertEquals("Large text content here", textRow.value) + } + + /** + * Test hint tag (H). + */ + @Test + fun `test hint tag`() { + val payload = """ + 0:"${'$'}L1" + 1:H["font","https://fonts.googleapis.com/css2?family=Inter"] + """.trimIndent() + + val resolver = FlightPayloadResolver() + resolver.parsePayloads(createPushesArray(payload)) + + val rows = resolver.getAllRows() + val hintRow = rows["1"] + + assertNotNull(hintRow) + assertIs(hintRow) + assertEquals("font", hintRow.code) + } + + /** + * Test missing reference (should not crash). + */ + @Test + fun `test missing reference returns placeholder`() { + val payload = """ + 0:"${'$'}L99" + """.trimIndent() + + val resolver = FlightPayloadResolver() + resolver.parsePayloads(createPushesArray(payload)) + + val result = resolver.getResolvedRoot() + assertNotNull(result) + + // Should return a placeholder for missing reference + assertIs(result) + assert(result.content.contains("not found")) + } + + /** + * Test circular reference protection. + */ + @Test + fun `test circular reference protection`() { + val payload = """ + 0:"${'$'}L1" + 1:{"ref":"${'$'}L0"} + """.trimIndent() + + val resolver = FlightPayloadResolver() + resolver.parsePayloads(createPushesArray(payload)) + + // Should not crash, should hit max depth + val result = resolver.getResolvedRoot() + assertNotNull(result) + } + + /** + * Test complex real-world example. + */ + @Test + fun `test real-world component tree`() { + val payload = """ + 0:"${'$'}L1" + 1:["${'$'}","html",null,{"lang":"en","children":["${'$'}L2","${'$'}L5"]}] + 2:["${'$'}","head",null,{"children":"${'$'}L3"}] + 3:["${'$'}","title",null,{"children":"My App"}] + 4:I["@/components/Navigation",["default"],"Navigation"] + 5:["${'$'}","body",null,{"children":["${'$'}L4","${'$'}L6"]}] + 6:["${'$'}","main",null,{"children":"${'$'}L7"}] + 7:["${'$'}","p",null,{"children":"Hello World"}] + """.trimIndent() + + val resolver = FlightPayloadResolver() + resolver.parsePayloads(createPushesArray(payload)) + + val result = resolver.getResolvedRoot() + assertNotNull(result) + + // Should be html element + assertIs(result) + assertEquals("html", result[1].jsonPrimitive.content) + + // Check props + val props = result[3].jsonObject + assertEquals("en", props["lang"]?.jsonPrimitive?.content) + + // Children should be resolved + val children = props["children"]?.jsonArray + assertNotNull(children) + assertEquals(2, children.size) + } + + /** + * Test custom tag handler registration. + */ + @Test + fun `test custom tag handler`() { + class CustomTagHandler : TagHandler { + override val tag: Char = 'X' + override fun parse(data: String): RowValue { + return RowValue.Text("Custom: $data") + } + } + + val payload = """ + 0:"${'$'}L1" + 1:XCustom data + """.trimIndent() + + val resolver = FlightPayloadResolver() + resolver.registerTagHandler(CustomTagHandler()) + resolver.parsePayloads(createPushesArray(payload)) + + val rows = resolver.getAllRows() + val customRow = rows["1"] + + assertNotNull(customRow) + assertIs(customRow) + assertEquals("Custom: Custom data", customRow.value) + } + + /** + * Helper to create JsonArray of pushes from payload string. + */ + private fun createPushesArray(payload: String): JsonArray { + return JsonArray( + listOf( + JsonArray( + listOf( + JsonPrimitive(1), + JsonPrimitive(payload + "\n") + ) + ) + ) + ) + } +} diff --git a/nextjs-rsc/src/jvmTest/kotlin/dev/kdriver/nextjs/rsc/ReferenceResolverTest.kt b/nextjs-rsc/src/jvmTest/kotlin/dev/kdriver/nextjs/rsc/ReferenceResolverTest.kt new file mode 100644 index 0000000..f6c41c4 --- /dev/null +++ b/nextjs-rsc/src/jvmTest/kotlin/dev/kdriver/nextjs/rsc/ReferenceResolverTest.kt @@ -0,0 +1,205 @@ +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 org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class ReferenceResolverTest { + + @Test + fun `resolve lazy reference`() { + val rows = mapOf( + "0" to RowValue.Model(JsonPrimitive("\$L1")), + "1" to RowValue.Model(JsonArray(listOf(JsonPrimitive("Hello")))) + ) + + val resolver = ReferenceResolver(rows) + val result = resolver.resolve(rows["0"]!!.let { (it as RowValue.Model).json }) + + assertIs(result) + assertEquals("Hello", result[0].jsonPrimitive.content) + } + + @Test + fun `resolve nested lazy references`() { + val rows = mapOf( + "0" to RowValue.Model(JsonPrimitive("\$L1")), + "1" to RowValue.Model(JsonPrimitive("\$L2")), + "2" to RowValue.Model(JsonPrimitive("Final value")) + ) + + val resolver = ReferenceResolver(rows) + val result = resolver.resolve(rows["0"]!!.let { (it as RowValue.Model).json }) + + assertIs(result) + assertEquals("Final value", result.content) + } + + @Test + fun `resolve promise reference`() { + val rows = mapOf( + "0" to RowValue.Model(JsonPrimitive("\$@1")), + "1" to RowValue.Model(JsonPrimitive("Async result")) + ) + + val resolver = ReferenceResolver(rows) + val result = resolver.resolve(rows["0"]!!.let { (it as RowValue.Model).json }) + + assertIs(result) + assertEquals("Async result", result.content) + } + + @Test + fun `resolve direct hex reference`() { + val rows = mapOf( + "0" to RowValue.Model(JsonPrimitive("\$1a")), + "1a" to RowValue.Model(JsonPrimitive("Hex ref value")) + ) + + val resolver = ReferenceResolver(rows) + val result = resolver.resolve(rows["0"]!!.let { (it as RowValue.Model).json }) + + assertIs(result) + assertEquals("Hex ref value", result.content) + } + + @Test + fun `resolve escaped dollar sign`() { + val json = Json.parseToJsonElement("""{"price":"${'$'}${'$'}100"}""") + val resolver = ReferenceResolver(emptyMap()) + val result = resolver.resolve(json) + + val obj = result.jsonObject + val priceValue = obj["price"]?.jsonPrimitive?.content + assertEquals("$100", priceValue) + } + + @Test + fun `resolve special literals`() { + val rows = mapOf( + "0" to RowValue.Model( + Json.parseToJsonElement( + """["${'$'}undefined","${'$'}Infinity","${'$'}-Infinity","${'$'}NaN"]""" + ) + ) + ) + + val resolver = ReferenceResolver(rows) + val result = resolver.resolve(rows["0"]!!.let { (it as RowValue.Model).json }) + + assertIs(result) + // First element should be null (undefined) + // Others should be infinity/nan + } + + @Test + fun `resolve references in nested objects`() { + val rows = mapOf( + "0" to RowValue.Model( + Json.parseToJsonElement("""{"nested":{"ref":"${'$'}L1"}}""") + ), + "1" to RowValue.Model(JsonPrimitive("Resolved")) + ) + + val resolver = ReferenceResolver(rows) + val result = resolver.resolve(rows["0"]!!.let { (it as RowValue.Model).json }) + + // Should resolve the nested reference + assertIs(result) + } + + @Test + fun `resolve references in arrays`() { + val rows = mapOf( + "0" to RowValue.Model( + Json.parseToJsonElement("""["${'$'}L1","${'$'}L2"]""") + ), + "1" to RowValue.Model(JsonPrimitive("First")), + "2" to RowValue.Model(JsonPrimitive("Second")) + ) + + val resolver = ReferenceResolver(rows) + val result = resolver.resolve(rows["0"]!!.let { (it as RowValue.Model).json }) + + assertIs(result) + assertEquals(2, result.size) + assertEquals("First", result[0].jsonPrimitive.content) + assertEquals("Second", result[1].jsonPrimitive.content) + } + + @Test + fun `resolve missing reference returns placeholder`() { + val rows = mapOf( + "0" to RowValue.Model(JsonPrimitive("\$L99")) + ) + + val resolver = ReferenceResolver(rows) + val result = resolver.resolve(rows["0"]!!.let { (it as RowValue.Model).json }) + + assertIs(result) + assert(result.content.contains("not found")) + } + + @Test + fun `resolve module reference`() { + val rows = mapOf( + "0" to RowValue.Model(JsonPrimitive("\$L1")), + "1" to RowValue.Module("@/components/Button", listOf("default"), "Button") + ) + + val resolver = ReferenceResolver(rows) + val result = resolver.resolve(rows["0"]!!.let { (it as RowValue.Model).json }) + + assertIs(result) + assert(result.content.contains("Module")) + assert(result.content.contains("Button")) + } + + @Test + fun `resolve error reference`() { + val rows = mapOf( + "0" to RowValue.Model(JsonPrimitive("\$L1")), + "1" to RowValue.Error("Not found", "stack trace", "digest") + ) + + val resolver = ReferenceResolver(rows) + val result = resolver.resolve(rows["0"]!!.let { (it as RowValue.Model).json }) + + assertIs(result) + assert(result.content.contains("Error")) + } + + @Test + fun `resolve text reference`() { + val rows = mapOf( + "0" to RowValue.Model(JsonPrimitive("\$L1")), + "1" to RowValue.Text("Large text content") + ) + + val resolver = ReferenceResolver(rows) + val result = resolver.resolve(rows["0"]!!.let { (it as RowValue.Model).json }) + + assertIs(result) + assertEquals("Large text content", result.content) + } + + @Test + fun `max depth protection prevents infinite loop`() { + val rows = mapOf( + "0" to RowValue.Model(JsonPrimitive("\$L1")), + "1" to RowValue.Model(JsonPrimitive("\$L0")) + ) + + val resolver = ReferenceResolver(rows, maxDepth = 10) + val result = resolver.resolve(rows["0"]!!.let { (it as RowValue.Model).json }) + + // Should not crash, should return something + assertIs(result) + } +} diff --git a/nextjs-rsc/src/jvmTest/kotlin/dev/kdriver/nextjs/rsc/RowParserTest.kt b/nextjs-rsc/src/jvmTest/kotlin/dev/kdriver/nextjs/rsc/RowParserTest.kt new file mode 100644 index 0000000..449bf53 --- /dev/null +++ b/nextjs-rsc/src/jvmTest/kotlin/dev/kdriver/nextjs/rsc/RowParserTest.kt @@ -0,0 +1,131 @@ +package dev.kdriver.nextjs.rsc + +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class RowParserTest { + + @Test + fun `parse simple model row without tag`() { + val row = RowParser.parseRow("""0:["$","div",null,{}]""") + + assertNotNull(row) + assertEquals("0", row.id) + assertNull(row.tag) + assertEquals("""["$","div",null,{}]""", row.data) + } + + @Test + fun `parse row with module tag`() { + val row = RowParser.parseRow("""1:I["path",["exports"],"name"]""") + + assertNotNull(row) + assertEquals("1", row.id) + assertEquals('I', row.tag) + assertEquals("""["path",["exports"],"name"]""", row.data) + } + + @Test + fun `parse row with tag and colon separator`() { + val row = RowParser.parseRow("""2:H:{"key":"value"}""") + + assertNotNull(row) + assertEquals("2", row.id) + assertEquals('H', row.tag) + assertEquals("""{"key":"value"}""", row.data) + } + + @Test + fun `parse row with text tag`() { + val row = RowParser.parseRow("""3:TLarge text content""") + + assertNotNull(row) + assertEquals("3", row.id) + assertEquals('T', row.tag) + assertEquals("Large text content", row.data) + } + + @Test + fun `parse row with error tag`() { + val row = RowParser.parseRow("""4:E{"message":"Error"}""") + + assertNotNull(row) + assertEquals("4", row.id) + assertEquals('E', row.tag) + assertEquals("""{"message":"Error"}""", row.data) + } + + @Test + fun `parse row with hex id`() { + val row = RowParser.parseRow("""1a:["$","p",null,{}]""") + + assertNotNull(row) + assertEquals("1a", row.id) + assertNull(row.tag) + assertEquals("""["$","p",null,{}]""", row.data) + } + + @Test + fun `parse row with lazy reference`() { + val row = RowParser.parseRow("""0:"${'$'}L1"""") + + assertNotNull(row) + assertEquals("0", row.id) + assertNull(row.tag) + assertEquals(""""${'$'}L1"""", row.data) + } + + @Test + fun `parse blank line returns null`() { + val row = RowParser.parseRow("") + assertNull(row) + } + + @Test + fun `parse line without colon returns null`() { + val row = RowParser.parseRow("invalid") + assertNull(row) + } + + @Test + fun `parse multiple rows`() { + val payload = """ + 0:"${'$'}L1" + 1:I["path",[],""] + 2:["${'$'}","div",null,{}] + """.trimIndent() + + val rows = RowParser.parseRows(payload) + + assertEquals(3, rows.size) + assertEquals("0", rows[0].id) + assertEquals("1", rows[1].id) + assertEquals("2", rows[2].id) + } + + @Test + fun `parse rows ignores blank lines`() { + val payload = """ + 0:"${'$'}L1" + + 1:["${'$'}","div",null,{}] + """.trimIndent() + + val rows = RowParser.parseRows(payload) + + assertEquals(2, rows.size) + } + + @Test + fun `parse row with data that looks like tag`() { + // "Test" should not be parsed as tag 'T' because 'e' follows + val row = RowParser.parseRow("""0:Testing data""") + + assertNotNull(row) + assertEquals("0", row.id) + assertNull(row.tag) + assertEquals("Testing data", row.data) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 771d63f..93ec21b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -12,3 +12,4 @@ plugins { include(":nextjs") include(":nextjs-engine") +include(":nextjs-rsc") From e09d5d0a056619de11ab8ff9600c6f52833a0e1e Mon Sep 17 00:00:00 2001 From: NathanFallet Date: Tue, 9 Dec 2025 15:39:38 +0100 Subject: [PATCH 3/5] lgtm --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 62ac360..5592bbb 100644 --- a/README.md +++ b/README.md @@ -26,10 +26,10 @@ dependencies { fun main() = runBlocking { val browser = createBrowser(this) val tab = browser.get("about:blank") - val allObjects = tab.capturePushesFromJs { // Or `capturePushesFromHtml` + val resolvedObject = tab.capturePushesFromJs { // Or `capturePushesFromHtml` tab.get(url) - fetchAll() + resolvedNextF() // Resolves Next.js specific data using RSC resolver } - println("Raw captured objects: ${allObjects.size}") + println(resolvedObject) } ``` From 0b1bf1061e6aeb87ce98f52f7baecb83ca51d57d Mon Sep 17 00:00:00 2001 From: NathanFallet Date: Tue, 9 Dec 2025 16:10:27 +0100 Subject: [PATCH 4/5] fix text handler + add tests --- .../dev/kdriver/nextjs/rsc/TagHandler.kt | 162 ------------------ .../kdriver/nextjs/rsc/TagHandlerRegistry.kt | 39 +++++ .../rsc/handlers/DebugInfoTagHandler.kt | 24 +++ .../nextjs/rsc/handlers/ErrorTagHandler.kt | 35 ++++ .../nextjs/rsc/handlers/HintTagHandler.kt | 41 +++++ .../nextjs/rsc/handlers/ModuleTagHandler.kt | 30 ++++ .../nextjs/rsc/handlers/TextTagHandler.kt | 42 +++++ .../nextjs/rsc/LengthEncodedTextTest.kt | 112 ++++++++++++ 8 files changed, 323 insertions(+), 162 deletions(-) create mode 100644 nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/TagHandlerRegistry.kt create mode 100644 nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/handlers/DebugInfoTagHandler.kt create mode 100644 nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/handlers/ErrorTagHandler.kt create mode 100644 nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/handlers/HintTagHandler.kt create mode 100644 nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/handlers/ModuleTagHandler.kt create mode 100644 nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/handlers/TextTagHandler.kt create mode 100644 nextjs-rsc/src/jvmTest/kotlin/dev/kdriver/nextjs/rsc/LengthEncodedTextTest.kt diff --git a/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/TagHandler.kt b/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/TagHandler.kt index 2833256..7ae83d5 100644 --- a/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/TagHandler.kt +++ b/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/TagHandler.kt @@ -1,11 +1,5 @@ package dev.kdriver.nextjs.rsc -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive - /** * Interface for handling specific RSC row tags. */ @@ -23,159 +17,3 @@ interface TagHandler { */ fun parse(data: String): RowValue } - -/** - * Registry for tag handlers. Allows easy addition of new handlers. - */ -class TagHandlerRegistry { - private val handlers = mutableMapOf() - - init { - // Register built-in handlers - register(ModuleTagHandler()) - register(HintTagHandler()) - register(ErrorTagHandler()) - register(TextTagHandler()) - register(DebugInfoTagHandler()) - } - - /** - * Register a new tag handler. - */ - fun register(handler: TagHandler) { - handlers[handler.tag] = handler - } - - /** - * Get handler for a specific tag. - */ - fun getHandler(tag: Char): TagHandler? = handlers[tag] - - /** - * Parse data using the appropriate handler. - */ - fun parse(tag: Char, data: String): RowValue { - return handlers[tag]?.parse(data) - ?: RowValue.Unknown(tag, data) - } -} - -/** - * Handler for module imports (tag: I). - * - * Format: ["path", ["export1", "export2"], "name"] - * Example: I["(app)/page", ["default"], ""] - */ -class ModuleTagHandler : TagHandler { - override val tag: Char = 'I' - - override fun parse(data: String): RowValue { - return try { - val json = Json.parseToJsonElement(data).jsonArray - RowValue.Module( - path = json[0].jsonPrimitive.content, - exports = json[1].jsonArray.map { it.jsonPrimitive.content }, - name = json.getOrNull(2)?.jsonPrimitive?.content ?: "" - ) - } catch (e: Exception) { - RowValue.Unknown(tag, data) - } - } -} - -/** - * Handler for hints (tag: H). - * - * Format: code:data or codedata - * Example: H["font", "https://fonts.googleapis.com/..."] - */ -class HintTagHandler : TagHandler { - override val tag: Char = 'H' - - override fun parse(data: String): RowValue { - return try { - // Hints can be in format: code + JSON array - // e.g., "H" + ["font", "url"] - val json = Json.parseToJsonElement(data).jsonArray - val code = json.getOrNull(0)?.jsonPrimitive?.content ?: "unknown" - RowValue.Hint(code, json) - } catch (e: Exception) { - // Fallback: treat first char as code - if (data.isNotEmpty()) { - val code = data[0].toString() - val rest = if (data.length > 1) data.substring(1) else "{}" - try { - val json = Json.parseToJsonElement(rest) - RowValue.Hint(code, json) - } catch (e2: Exception) { - RowValue.Unknown(tag, data) - } - } else { - RowValue.Unknown(tag, data) - } - } - } -} - -/** - * Handler for errors (tag: E). - * - * Format: {"message": "...", "stack": "...", "digest": "..."} - * Example: E{"message": "Not found", "stack": "..."} - */ -class ErrorTagHandler : TagHandler { - override val tag: Char = 'E' - - override fun parse(data: String): RowValue { - return try { - val json = Json.parseToJsonElement(data).jsonObject - RowValue.Error( - message = json["message"]?.jsonPrimitive?.content - ?: "Unknown error", - stack = json["stack"]?.jsonPrimitive?.content, - digest = json["digest"]?.jsonPrimitive?.content - ) - } catch (e: Exception) { - RowValue.Error( - message = "Failed to parse error: ${e.message}", - stack = null, - digest = null - ) - } - } -} - -/** - * Handler for text chunks (tag: T). - * - * Format: plain text or with length encoding - * Example: T:10,Hello World - */ -class TextTagHandler : TagHandler { - override val tag: Char = 'T' - - override fun parse(data: String): RowValue { - // For now, treat entire data as text - // TODO: Handle length-based encoding if needed - return RowValue.Text(data) - } -} - -/** - * Handler for debug info (tag: D). - * - * Format: JSON debug metadata - * Example: D{"name": "Component", "stack": "..."} - */ -class DebugInfoTagHandler : TagHandler { - override val tag: Char = 'D' - - override fun parse(data: String): RowValue { - return try { - val json = Json.parseToJsonElement(data) - RowValue.DebugInfo(json) - } catch (e: Exception) { - RowValue.Unknown(tag, data) - } - } -} diff --git a/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/TagHandlerRegistry.kt b/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/TagHandlerRegistry.kt new file mode 100644 index 0000000..453f070 --- /dev/null +++ b/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/TagHandlerRegistry.kt @@ -0,0 +1,39 @@ +package dev.kdriver.nextjs.rsc + +import dev.kdriver.nextjs.rsc.handlers.* + +/** + * Registry for tag handlers. Allows easy addition of new handlers. + */ +class TagHandlerRegistry { + private val handlers = mutableMapOf() + + init { + // Register built-in handlers + register(ModuleTagHandler()) + register(HintTagHandler()) + register(ErrorTagHandler()) + register(TextTagHandler()) + register(DebugInfoTagHandler()) + } + + /** + * Register a new tag handler. + */ + fun register(handler: TagHandler) { + handlers[handler.tag] = handler + } + + /** + * Get handler for a specific tag. + */ + fun getHandler(tag: Char): TagHandler? = handlers[tag] + + /** + * Parse data using the appropriate handler. + */ + fun parse(tag: Char, data: String): RowValue { + return handlers[tag]?.parse(data) + ?: RowValue.Unknown(tag, data) + } +} diff --git a/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/handlers/DebugInfoTagHandler.kt b/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/handlers/DebugInfoTagHandler.kt new file mode 100644 index 0000000..0ed75ba --- /dev/null +++ b/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/handlers/DebugInfoTagHandler.kt @@ -0,0 +1,24 @@ +package dev.kdriver.nextjs.rsc.handlers + +import dev.kdriver.nextjs.rsc.RowValue +import dev.kdriver.nextjs.rsc.TagHandler +import kotlinx.serialization.json.Json + +/** + * Handler for debug info (tag: D). + * + * Format: JSON debug metadata + * Example: D{"name": "Component", "stack": "..."} + */ +class DebugInfoTagHandler : TagHandler { + override val tag: Char = 'D' + + override fun parse(data: String): RowValue { + return try { + val json = Json.parseToJsonElement(data) + RowValue.DebugInfo(json) + } catch (e: Exception) { + RowValue.Unknown(tag, data) + } + } +} diff --git a/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/handlers/ErrorTagHandler.kt b/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/handlers/ErrorTagHandler.kt new file mode 100644 index 0000000..a39993f --- /dev/null +++ b/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/handlers/ErrorTagHandler.kt @@ -0,0 +1,35 @@ +package dev.kdriver.nextjs.rsc.handlers + +import dev.kdriver.nextjs.rsc.RowValue +import dev.kdriver.nextjs.rsc.TagHandler +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +/** + * Handler for errors (tag: E). + * + * Format: {"message": "...", "stack": "...", "digest": "..."} + * Example: E{"message": "Not found", "stack": "..."} + */ +class ErrorTagHandler : TagHandler { + override val tag: Char = 'E' + + override fun parse(data: String): RowValue { + return try { + val json = Json.parseToJsonElement(data).jsonObject + RowValue.Error( + message = json["message"]?.jsonPrimitive?.content + ?: "Unknown error", + stack = json["stack"]?.jsonPrimitive?.content, + digest = json["digest"]?.jsonPrimitive?.content + ) + } catch (e: Exception) { + RowValue.Error( + message = "Failed to parse error: ${e.message}", + stack = null, + digest = null + ) + } + } +} diff --git a/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/handlers/HintTagHandler.kt b/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/handlers/HintTagHandler.kt new file mode 100644 index 0000000..3fbccb3 --- /dev/null +++ b/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/handlers/HintTagHandler.kt @@ -0,0 +1,41 @@ +package dev.kdriver.nextjs.rsc.handlers + +import dev.kdriver.nextjs.rsc.RowValue +import dev.kdriver.nextjs.rsc.TagHandler +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonPrimitive + +/** + * Handler for hints (tag: H). + * + * Format: code:data or codedata + * Example: H["font", "https://fonts.googleapis.com/..."] + */ +class HintTagHandler : TagHandler { + override val tag: Char = 'H' + + override fun parse(data: String): RowValue { + return try { + // Hints can be in format: code + JSON array + // e.g., "H" + ["font", "url"] + val json = Json.parseToJsonElement(data).jsonArray + val code = json.getOrNull(0)?.jsonPrimitive?.content ?: "unknown" + RowValue.Hint(code, json) + } catch (e: Exception) { + // Fallback: treat first char as code + if (data.isNotEmpty()) { + val code = data[0].toString() + val rest = if (data.length > 1) data.substring(1) else "{}" + try { + val json = Json.parseToJsonElement(rest) + RowValue.Hint(code, json) + } catch (e2: Exception) { + RowValue.Unknown(tag, data) + } + } else { + RowValue.Unknown(tag, data) + } + } + } +} diff --git a/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/handlers/ModuleTagHandler.kt b/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/handlers/ModuleTagHandler.kt new file mode 100644 index 0000000..ceb4f05 --- /dev/null +++ b/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/handlers/ModuleTagHandler.kt @@ -0,0 +1,30 @@ +package dev.kdriver.nextjs.rsc.handlers + +import dev.kdriver.nextjs.rsc.RowValue +import dev.kdriver.nextjs.rsc.TagHandler +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonPrimitive + +/** + * Handler for module imports (tag: I). + * + * Format: ["path", ["export1", "export2"], "name"] + * Example: I["(app)/page", ["default"], ""] + */ +class ModuleTagHandler : TagHandler { + override val tag: Char = 'I' + + override fun parse(data: String): RowValue { + return try { + val json = Json.parseToJsonElement(data).jsonArray + RowValue.Module( + path = json[0].jsonPrimitive.content, + exports = json[1].jsonArray.map { it.jsonPrimitive.content }, + name = json.getOrNull(2)?.jsonPrimitive?.content ?: "" + ) + } catch (e: Exception) { + RowValue.Unknown(tag, data) + } + } +} diff --git a/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/handlers/TextTagHandler.kt b/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/handlers/TextTagHandler.kt new file mode 100644 index 0000000..3711179 --- /dev/null +++ b/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/handlers/TextTagHandler.kt @@ -0,0 +1,42 @@ +package dev.kdriver.nextjs.rsc.handlers + +import dev.kdriver.nextjs.rsc.RowValue +import dev.kdriver.nextjs.rsc.TagHandler + +/** + * Handler for text chunks (tag: T). + * + * Format: plain text or with length encoding + * Examples: + * - T:Simple text → "Simple text" + * - T10,Hello World → "Hello World" (10 = 0x10 = 16 bytes length) + * - T7db, (7db = 0x7db = 2011 bytes length) + */ +class TextTagHandler : TagHandler { + override val tag: Char = 'T' + + override fun parse(data: String): RowValue { + // Check if data starts with length encoding: , + val commaIndex = data.indexOf(',') + + if (commaIndex > 0) { + // Try to parse the prefix as hex length + val potentialLength = data.substring(0, commaIndex) + + // Check if it's a valid hex number + if (potentialLength.all { it in '0'..'9' || it in 'a'..'f' || it in 'A'..'F' }) { + // It's length-encoded text + // Extract the actual text after the comma + val text = if (commaIndex + 1 < data.length) { + data.substring(commaIndex + 1) + } else { + "" + } + return RowValue.Text(text) + } + } + + // No length encoding, treat entire data as text + return RowValue.Text(data) + } +} diff --git a/nextjs-rsc/src/jvmTest/kotlin/dev/kdriver/nextjs/rsc/LengthEncodedTextTest.kt b/nextjs-rsc/src/jvmTest/kotlin/dev/kdriver/nextjs/rsc/LengthEncodedTextTest.kt new file mode 100644 index 0000000..8e72b3c --- /dev/null +++ b/nextjs-rsc/src/jvmTest/kotlin/dev/kdriver/nextjs/rsc/LengthEncodedTextTest.kt @@ -0,0 +1,112 @@ +package dev.kdriver.nextjs.rsc + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +class LengthEncodedTextTest { + + /** + * Test case from user: Text tag with length encoding. + * Format: `d1:T7db,` where 7db is hex length (2011 bytes). + * The actual text content comes in the next line/push. + */ + @Test + fun `test text tag with length encoding`() { + // Simpler test case - hex length 10 (16 bytes) + val payload = """ + 0:{"description":"${'$'}d1"} + d1:T10,This is a text + """.trimIndent() + + val resolver = FlightPayloadResolver() + resolver.parsePayloads(createPushesArray(payload)) + + val result = resolver.getResolvedRoot() + assertNotNull(result) + + val obj = result.jsonObject + val description = obj["description"]?.jsonPrimitive?.content + + // Should resolve to the full text, not just "This is a text" + assertEquals("This is a text", description) + } + + /** + * Real-world test case from user. + * Row d1 has format: `d1:T7db,` (7db hex = 2011 decimal bytes) + * The actual content follows. + */ + @Test + fun `test real-world description with length encoding`() { + // Simpler version to debug + val payload = """ + 0:{"description":"${'$'}d1"} + d1:T7db,-20% sur les lots ! 🎁 + """.trimIndent() + + val resolver = FlightPayloadResolver() + resolver.parsePayloads(createPushesArray(payload)) + + // First check: does the row parse correctly? + val rows = resolver.getAllRows() + val d1Row = rows["d1"] + assertNotNull(d1Row, "Row d1 should exist") + assert(d1Row is RowValue.Text) { "Row d1 should be Text, got: ${d1Row::class.simpleName}" } + val d1Text = (d1Row as RowValue.Text).value + println("DEBUG: Row d1 text value: '$d1Text'") + assert(d1Text.startsWith("-20%")) { "Row d1 text should start with '-20%', got: '$d1Text'" } + + // Second check: does the reference resolve correctly? + val result = resolver.getResolvedRoot() + assertNotNull(result) + + val obj = result.jsonObject + val description = obj["description"]?.jsonPrimitive?.content + + // Should resolve to the full text, not just "7db," + assertNotNull(description, "Description should not be null") + assert(description.contains("-20% sur les lots")) { + "Expected description to contain the actual text, but got: '$description'" + } + } + + /** + * Test that text WITHOUT length encoding still works. + */ + @Test + fun `test text tag without length encoding`() { + val payload = """ + 0:{"description":"${'$'}d1"} + d1:TSimple text content + """.trimIndent() + + val resolver = FlightPayloadResolver() + resolver.parsePayloads(createPushesArray(payload)) + + val result = resolver.getResolvedRoot() + assertNotNull(result) + + val obj = result.jsonObject + val description = obj["description"]?.jsonPrimitive?.content + + assertEquals("Simple text content", description) + } + + private fun createPushesArray(payload: String): JsonArray { + return JsonArray( + listOf( + JsonArray( + listOf( + JsonPrimitive(1), + JsonPrimitive(payload + "\n") + ) + ) + ) + ) + } +} From eb45e0bc05d24d8f19da324bfafce47e00c93086 Mon Sep 17 00:00:00 2001 From: NathanFallet Date: Tue, 9 Dec 2025 16:25:59 +0100 Subject: [PATCH 5/5] implement new payload resolver for multi-push text handling + add tests --- .../nextjs/rsc/FlightPayloadResolver.kt | 29 ++++ .../kdriver/nextjs/rsc/MultiPushTextTest.kt | 143 ++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 nextjs-rsc/src/jvmTest/kotlin/dev/kdriver/nextjs/rsc/MultiPushTextTest.kt diff --git a/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/FlightPayloadResolver.kt b/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/FlightPayloadResolver.kt index 3e208f0..f537258 100644 --- a/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/FlightPayloadResolver.kt +++ b/nextjs-rsc/src/commonMain/kotlin/dev/kdriver/nextjs/rsc/FlightPayloadResolver.kt @@ -51,6 +51,12 @@ class FlightPayloadResolver( */ private val rows = mutableMapOf() + /** + * Pending text row ID waiting for data in next push. + * Set when we encounter a text tag with length encoding but no data. + */ + private var pendingTextRowId: String? = null + /** * Parse all __next_f pushes and build the row map. * @@ -63,6 +69,14 @@ class FlightPayloadResolver( * ] * ``` * + * Special case for multi-push text: + * ```json + * [ + * [1, "d1:T7db,\n"], // Text tag with no data after comma + * [1, "Actual text..."] // Next push contains the text + * ] + * ``` + * * @param pushes JsonArray of push events from __next_f */ fun parsePayloads(pushes: JsonArray) { @@ -86,11 +100,25 @@ class FlightPayloadResolver( * @param payload The payload string with newline-separated rows */ private fun parsePayload(payload: String) { + // Check if we have a pending text row from previous push + if (pendingTextRowId != null) { + // This payload is the text content for the pending row + rows[pendingTextRowId!!] = RowValue.Text(payload) + pendingTextRowId = null + return + } + val parsedRows = RowParser.parseRows(payload) for (row in parsedRows) { val rowValue = parseRowValue(row) rows[row.id] = rowValue + + // Check if this is a text row waiting for data in next push + if (rowValue is RowValue.Text && rowValue.value.isEmpty() && row.tag == 'T') { + // Text tag with no data - expect data in next push + pendingTextRowId = row.id + } } } @@ -219,5 +247,6 @@ class FlightPayloadResolver( */ fun clear() { rows.clear() + pendingTextRowId = null } } diff --git a/nextjs-rsc/src/jvmTest/kotlin/dev/kdriver/nextjs/rsc/MultiPushTextTest.kt b/nextjs-rsc/src/jvmTest/kotlin/dev/kdriver/nextjs/rsc/MultiPushTextTest.kt new file mode 100644 index 0000000..ba1906b --- /dev/null +++ b/nextjs-rsc/src/jvmTest/kotlin/dev/kdriver/nextjs/rsc/MultiPushTextTest.kt @@ -0,0 +1,143 @@ +package dev.kdriver.nextjs.rsc + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +class MultiPushTextTest { + + /** + * Test case where text data is in a separate push. + * This is the real format from the user's example. + * + * Push 1: Row definition with length encoding but no data + * Push 2: Raw text content (no row ID) + */ + @Test + fun `test text tag with data in next push`() { + // Create two separate pushes + val pushes = JsonArray( + listOf( + // Push 1: Row definitions + JsonArray( + listOf( + JsonPrimitive(1), + JsonPrimitive("0:{\"description\":\"\$d1\"}\nd1:T7db,\n") + ) + ), + // Push 2: Text content (no row ID) + JsonArray( + listOf( + JsonPrimitive(1), + JsonPrimitive("-20% sur les lots ! 🎁") + ) + ) + ) + ) + + val resolver = FlightPayloadResolver() + resolver.parsePayloads(pushes) + + val result = resolver.getResolvedRoot() + assertNotNull(result) + + val obj = result.jsonObject + val description = obj["description"]?.jsonPrimitive?.content + + println("DEBUG: Description = '$description'") + + // Should resolve to the full text from push 2 + assertNotNull(description, "Description should not be null") + assertEquals("-20% sur les lots ! 🎁", description) + } + + /** + * Real-world example from user: product description + */ + @Test + fun `test real-world multi-push description`() { + val longText = """-20% sur les lots ! 🎁 + +➡ Marques disponibles dans mon dressing : Ralph Lauren, Carhartt, Levis, Lacoste, The North Face, WRSTBHVR ...""" + + val pushes = JsonArray( + listOf( + // Push 1: Row definitions including "d1:T7db," with no text after comma + JsonArray( + listOf( + JsonPrimitive(1), + JsonPrimitive("38:\"\$34:metadata\"\nd1:T7db,\n") + ) + ), + // Push 2: The actual text content (no row ID!) + JsonArray( + listOf( + JsonPrimitive(1), + JsonPrimitive(longText) + ) + ) + ) + ) + + val resolver = FlightPayloadResolver() + resolver.parsePayloads(pushes) + + // Check that row d1 exists and has the text from push 2 + val rows = resolver.getAllRows() + val d1Row = rows["d1"] + + assertNotNull(d1Row, "Row d1 should exist") + assert(d1Row is RowValue.Text) { "Row d1 should be Text, got: ${d1Row::class.simpleName}" } + + val d1Text = (d1Row as RowValue.Text).value + println("DEBUG: Row d1 text = '$d1Text'") + + assert(d1Text.contains("-20% sur les lots")) { + "Expected text to contain '-20% sur les lots', but got: '$d1Text'" + } + } + + /** + * Test multiple rows with some having multi-push text. + */ + @Test + fun `test multiple rows with mixed single and multi-push text`() { + val pushes = JsonArray( + listOf( + // Push 1: Multiple row definitions + JsonArray( + listOf( + JsonPrimitive(1), + JsonPrimitive("0:{\"a\":\"\$1\",\"b\":\"\$2\"}\n1:TSingle line text\n2:T100,\n") + ) + ), + // Push 2: Text for row 2 (which was defined with length encoding) + JsonArray( + listOf( + JsonPrimitive(1), + JsonPrimitive("Multi-push text content") + ) + ) + ) + ) + + val resolver = FlightPayloadResolver() + resolver.parsePayloads(pushes) + + val rows = resolver.getAllRows() + + // Row 1 should have single-line text + val row1 = rows["1"] as? RowValue.Text + assertNotNull(row1) + assertEquals("Single line text", row1.value) + + // Row 2 should have multi-push text + val row2 = rows["2"] as? RowValue.Text + assertNotNull(row2) + assertEquals("Multi-push text content", row2.value) + } +}