@kizunajs/kotlin 2.0.0-beta.20 → 2.0.0-beta.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- const require_generator = require("./generator-Bg4HlRb3.cjs");
2
+ const require_generator = require("./generator-Dft2DL-3.cjs");
3
3
  let node_fs = require("node:fs");
4
4
  let node_path = require("node:path");
5
5
  let node_util = require("node:util");
package/dist/cli.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { t as generateKotlinClient } from "./generator-D4GvH1-a.mjs";
2
+ import { t as generateKotlinClient } from "./generator-DHi8XSWb.mjs";
3
3
  import { mkdirSync, writeFileSync } from "node:fs";
4
4
  import { dirname, resolve } from "node:path";
5
5
  import { parseArgs } from "node:util";
@@ -1476,6 +1476,35 @@ const emitErrorBranches = (writer, method, context) => {
1476
1476
  }
1477
1477
  writer.line(`else -> throw ${failureRef}.Unexpected(statusCode = statusCode, data = data)`);
1478
1478
  };
1479
+ const IDEMPOTENT_METHODS = new Set([
1480
+ "GET",
1481
+ "HEAD",
1482
+ "OPTIONS",
1483
+ "PUT",
1484
+ "DELETE"
1485
+ ]);
1486
+ /**
1487
+ * Writes the reconnection an event stream reopens through and returns the argument handing it to `Kizuna.events`, or
1488
+ * nothing for a method a client may not resend.
1489
+ */
1490
+ const emitReopen = (writer, method, operationRef, status) => {
1491
+ if (!IDEMPOTENT_METHODS.has(method.method)) return "";
1492
+ closure(writer, "val reconnection = reconnect?.let { policy ->", () => {
1493
+ closure(writer, "Kizuna.Reconnection(policy) { lastEventId ->", () => {
1494
+ writer.line("val again = requestBuilder.build().newBuilder()");
1495
+ writer.line("if (lastEventId.isNotEmpty()) again.header(\"Last-Event-ID\", lastEventId)");
1496
+ writer.line("requestInterceptor?.invoke(again)");
1497
+ writer.line("val reopened = Kizuna.send(client, again.build(), retry)");
1498
+ writer.line("val eventStream = reopened.header(\"Content-Type\")?.lowercase()?.startsWith(\"text/event-stream\") == true");
1499
+ writer.block("when", () => {
1500
+ writer.line("reopened.code == 204 -> { reopened.close(); null }");
1501
+ writer.line(`reopened.code == ${status} && eventStream -> reopened`);
1502
+ writer.line(`else -> { reopened.close(); throw ${operationRef}.Failure.Unexpected(statusCode = reopened.code, data = ByteArray(0)) }`);
1503
+ });
1504
+ });
1505
+ });
1506
+ return ", reconnection";
1507
+ };
1479
1508
  const emitStreamMethodTail = (writer, method, context) => {
1480
1509
  const operationRef = `${context.clientName}.${method.operationName}`;
1481
1510
  const stream = method.stream;
@@ -1488,7 +1517,7 @@ const emitStreamMethodTail = (writer, method, context) => {
1488
1517
  else if (stream.events) {
1489
1518
  const events = stream.events;
1490
1519
  const elementType = streamElementType(method, context, "client");
1491
- closure(writer, `val body = Kizuna.events<${elementType}>(httpResponse) { event ->`, () => {
1520
+ closure(writer, `val body = Kizuna.events<${elementType}>(httpResponse${emitReopen(writer, method, operationRef, stream.status)}) { event ->`, () => {
1492
1521
  writer.block("when (event.event)", () => {
1493
1522
  for (const event of events) {
1494
1523
  const payloadType = resolveType(event.type, method.operationName, context);
@@ -1499,7 +1528,8 @@ const emitStreamMethodTail = (writer, method, context) => {
1499
1528
  });
1500
1529
  } else {
1501
1530
  const elementType = streamElementType(method, context, "client");
1502
- writer.line(`val body = Kizuna.events<${elementType}>(httpResponse) { event -> json.decodeFromString<${elementType}>(event.data) }`);
1531
+ const reconnection = emitReopen(writer, method, operationRef, stream.status);
1532
+ writer.line(`val body = Kizuna.events<${elementType}>(httpResponse${reconnection}) { event -> json.decodeFromString<${elementType}>(event.data) }`);
1503
1533
  }
1504
1534
  if (hasHeaders) {
1505
1535
  for (const field of successResponse.responseHeaders) writer.line(`val ${escapeKeyword(field.name)} = httpResponse.header(${stringLiteral(field.wireName)})`);
@@ -1545,7 +1575,7 @@ const emitSubClientMethod = (writer, method, context) => {
1545
1575
  };
1546
1576
  const emitSubClientClass = (writer, group, clientName, context) => {
1547
1577
  writer.blank();
1548
- writer.block(`class ${group.className}(private val client: OkHttpClient, private val baseUrl: String, private val json: Json, private val requestContextHeaders: Map<String, String>, private val requestInterceptor: (suspend (Request.Builder) -> Unit)?, private val responseInterceptor: (suspend (Request, Response) -> Unit)?, private val retry: ${clientName}.RetryPolicy?)`, () => {
1578
+ writer.block(`class ${group.className}(private val client: OkHttpClient, private val baseUrl: String, private val json: Json, private val requestContextHeaders: Map<String, String>, private val requestInterceptor: (suspend (Request.Builder) -> Unit)?, private val responseInterceptor: (suspend (Request, Response) -> Unit)?, private val retry: ${clientName}.RetryPolicy?, private val reconnect: ${clientName}.ReconnectPolicy?)`, () => {
1549
1579
  for (const method of group.methods) {
1550
1580
  writer.blank();
1551
1581
  emitSubClientMethod(writer, method, context);
@@ -1557,10 +1587,12 @@ const closure = (writer, opener, body) => {
1557
1587
  writer.indent(body);
1558
1588
  writer.line("}");
1559
1589
  };
1560
- const emitKizunaStreaming = (writer) => {
1590
+ const emitKizunaStreaming = (writer, clientName) => {
1561
1591
  writer.blank();
1562
1592
  writer.line("data class ServerSentEvent(val event: String?, val data: String, val id: String?, val retry: Int?)");
1563
1593
  writer.blank();
1594
+ writer.line(`class Reconnection(val policy: ${clientName}.ReconnectPolicy, val reopen: suspend (String) -> Response?)`);
1595
+ writer.blank();
1564
1596
  writer.block("private suspend fun readLine(source: okio.BufferedSource): String?", () => {
1565
1597
  writer.line("return kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { source.readUtf8Line() }");
1566
1598
  });
@@ -1587,44 +1619,83 @@ const emitKizunaStreaming = (writer) => {
1587
1619
  });
1588
1620
  });
1589
1621
  writer.blank();
1590
- writer.block("fun serverSentEvents(response: Response): Flow<ServerSentEvent> = flow", () => {
1591
- closure(writer, "response.use { open ->", () => {
1592
- writer.line("val source = open.body?.source() ?: return@use");
1593
- writer.line("var eventType = \"\"");
1594
- writer.line("val dataLines = mutableListOf<String>()");
1595
- writer.line("var lastEventId = \"\"");
1596
- writer.line("var retry: Int? = null");
1597
- writer.block("while (true)", () => {
1598
- writer.line("val line = readLine(source) ?: break");
1599
- writer.block("if (line.isEmpty())", () => {
1600
- writer.block("if (dataLines.isNotEmpty())", () => {
1601
- writer.line("emit(ServerSentEvent(eventType.ifEmpty { null }, dataLines.joinToString(\"\\n\"), lastEventId.ifEmpty { null }, retry))");
1602
- writer.line("dataLines.clear()");
1603
- writer.line("retry = null");
1622
+ writer.line("private val LINE_ENDINGS = okio.ByteString.of('\\r'.code.toByte(), '\\n'.code.toByte())");
1623
+ writer.blank();
1624
+ writer.block("private suspend fun readEventLine(source: okio.BufferedSource): String?", () => {
1625
+ closure(writer, "return kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {", () => {
1626
+ writer.line("val end = source.indexOfElement(LINE_ENDINGS)");
1627
+ writer.block("if (end == -1L)", () => {
1628
+ writer.line("if (source.exhausted()) null else source.readUtf8()");
1629
+ });
1630
+ writer.block("else", () => {
1631
+ writer.line("val line = source.readUtf8(end)");
1632
+ writer.line("val carriageReturn = source.readByte() == '\\r'.code.toByte()");
1633
+ writer.line("if (carriageReturn && source.request(1) && source.buffer[0] == '\\n'.code.toByte()) source.skip(1)");
1634
+ writer.line("line");
1635
+ });
1636
+ });
1637
+ });
1638
+ writer.blank();
1639
+ writer.block("fun serverSentEvents(response: Response, reconnect: Reconnection?): Flow<ServerSentEvent> = flow", () => {
1640
+ writer.line("var lastEventId = \"\"");
1641
+ writer.line("var reconnectionTime = reconnect?.policy?.reconnectionTime ?: 3.seconds");
1642
+ writer.line("var current: Response? = response");
1643
+ writer.line("var attempts = 0");
1644
+ writer.line("var failure: Throwable? = null");
1645
+ writer.block("while (true)", () => {
1646
+ closure(writer, "current?.use { open ->", () => {
1647
+ writer.line("val source = open.body?.source() ?: return@use");
1648
+ writer.line("var eventType = \"\"");
1649
+ writer.line("val dataLines = mutableListOf<String>()");
1650
+ writer.line("var pendingId = lastEventId");
1651
+ writer.line("var retry: Int? = null");
1652
+ writer.line("var firstLine = true");
1653
+ writer.block("while (true)", () => {
1654
+ writer.line("val received = try { readEventLine(source) } catch (error: java.io.IOException) { failure = error; null } ?: break");
1655
+ writer.line("val line = if (firstLine) received.removePrefix(\"\\uFEFF\") else received");
1656
+ writer.line("firstLine = false");
1657
+ writer.block("if (line.isEmpty())", () => {
1658
+ writer.line("lastEventId = pendingId");
1659
+ writer.block("if (dataLines.isNotEmpty())", () => {
1660
+ writer.line("emit(ServerSentEvent(eventType.ifEmpty { null }, dataLines.joinToString(\"\\n\"), lastEventId.ifEmpty { null }, retry))");
1661
+ writer.line("attempts = 0");
1662
+ writer.line("failure = null");
1663
+ writer.line("dataLines.clear()");
1664
+ writer.line("retry = null");
1665
+ });
1666
+ writer.line("eventType = \"\"");
1667
+ writer.line("continue");
1668
+ });
1669
+ writer.line("if (line.startsWith(\":\")) continue");
1670
+ writer.line("val separator = line.indexOf(':')");
1671
+ writer.line("val field = if (separator == -1) line else line.substring(0, separator)");
1672
+ writer.line("val value = (if (separator == -1) \"\" else line.substring(separator + 1)).removePrefix(\" \")");
1673
+ writer.block("when (field)", () => {
1674
+ writer.line("\"event\" -> eventType = value");
1675
+ writer.line("\"data\" -> dataLines.add(value)");
1676
+ writer.line("\"id\" -> if (!value.contains('\\u0000')) pendingId = value");
1677
+ writer.line("\"retry\" -> if (value.isNotEmpty() && value.all { character -> character in '0'..'9' }) value.toIntOrNull()?.let { milliseconds -> retry = milliseconds; reconnectionTime = minOf(milliseconds.milliseconds, 3_600_000.milliseconds) }");
1604
1678
  });
1605
- writer.line("eventType = \"\"");
1606
- writer.line("continue");
1607
- });
1608
- writer.line("if (line.startsWith(\":\")) continue");
1609
- writer.line("val separator = line.indexOf(':')");
1610
- writer.line("val field = if (separator == -1) line else line.substring(0, separator)");
1611
- writer.line("val value = (if (separator == -1) \"\" else line.substring(separator + 1)).removePrefix(\" \")");
1612
- writer.block("when (field)", () => {
1613
- writer.line("\"event\" -> eventType = value");
1614
- writer.line("\"data\" -> dataLines.add(value)");
1615
- writer.line("\"id\" -> if (!value.contains('\\u0000')) lastEventId = value");
1616
- writer.line("\"retry\" -> if (value.isNotEmpty() && value.all { it.isDigit() }) retry = value.toIntOrNull()");
1617
1679
  });
1618
1680
  });
1681
+ writer.block("if (reconnect == null || attempts >= reconnect.policy.maximumAttempts)", () => {
1682
+ writer.line("failure?.let { throw it }");
1683
+ writer.line("return@flow");
1684
+ });
1685
+ writer.line("attempts += 1");
1686
+ writer.line("val ceiling = minOf(reconnect.policy.maximumDelay, reconnectionTime * Math.pow(2.0, (attempts - 1).toDouble()))");
1687
+ writer.line("val extra = (ceiling - reconnectionTime).coerceAtLeast(kotlin.time.Duration.ZERO)");
1688
+ writer.line("kotlinx.coroutines.delay(reconnectionTime + extra * Math.random())");
1689
+ writer.line("current = try { reconnect.reopen(lastEventId) ?: return@flow } catch (error: java.io.IOException) { failure = error; null }");
1619
1690
  });
1620
1691
  });
1621
1692
  writer.blank();
1622
- writer.line("fun <Event : Any> events(response: Response, decode: (ServerSentEvent) -> Event?): Flow<Event> = serverSentEvents(response).mapNotNull(decode)");
1693
+ writer.line("fun <Event : Any> events(response: Response, reconnect: Reconnection? = null, decode: (ServerSentEvent) -> Event?): Flow<Event> = serverSentEvents(response, reconnect).mapNotNull(decode)");
1623
1694
  };
1624
1695
  const emitKizunaObject = (writer, options) => {
1625
1696
  writer.blank();
1626
1697
  writer.block("private object Kizuna", () => {
1627
- if (options.streaming) emitKizunaStreaming(writer);
1698
+ if (options.streaming) emitKizunaStreaming(writer, options.clientName);
1628
1699
  writer.block("fun resolveUrl(baseUrl: String, path: String): HttpUrl.Builder", () => {
1629
1700
  writer.line("val base = baseUrl.toHttpUrl()");
1630
1701
  writer.line("val trimmedPath = path.trimStart('/')");
@@ -1721,7 +1792,7 @@ const emitClient = (writer, config, partition, context, typesByOperation, regist
1721
1792
  const contextRequired = contextFields.some((field) => !hasDefault(field));
1722
1793
  const contextParam = contextFields.length > 0 ? `requestContext: RequestContext${contextRequired ? "" : " = RequestContext()"}, ` : "";
1723
1794
  writer.blank();
1724
- writer.block(`class ${clientName}(private val baseUrl: String, ${contextParam}private val client: OkHttpClient = OkHttpClient(), private val json: Json = Json { ignoreUnknownKeys = true }, private val requestInterceptor: (suspend (Request.Builder) -> Unit)? = null, private val responseInterceptor: (suspend (Request, Response) -> Unit)? = null, private val retry: RetryPolicy? = null)`, () => {
1795
+ writer.block(`class ${clientName}(private val baseUrl: String, ${contextParam}private val client: OkHttpClient = OkHttpClient(), private val json: Json = Json { ignoreUnknownKeys = true }, private val requestInterceptor: (suspend (Request.Builder) -> Unit)? = null, private val responseInterceptor: (suspend (Request, Response) -> Unit)? = null, private val retry: RetryPolicy? = null, private val reconnect: ReconnectPolicy? = null)`, () => {
1725
1796
  if (contextFields.length > 0) {
1726
1797
  writer.blank();
1727
1798
  writer.line("/** Values sent as headers on every request, from the api's request context. */");
@@ -1746,6 +1817,20 @@ const emitClient = (writer, config, partition, context, typesByOperation, regist
1746
1817
  "val initialDelay: kotlin.time.Duration = 500.milliseconds",
1747
1818
  "val maximumDelay: kotlin.time.Duration = 30.seconds"
1748
1819
  ]);
1820
+ writer.blank();
1821
+ writer.line("/**");
1822
+ writer.line(" * Reconnects an event stream on a GET, HEAD, OPTIONS, PUT or DELETE when its connection drops,");
1823
+ writer.line(" * sending the id of the last event as Last-Event-ID.");
1824
+ writer.line(" *");
1825
+ writer.line(" * @property maximumAttempts Reconnections in a row before the stream fails. An event received resets the count.");
1826
+ writer.line(" * @property reconnectionTime What to wait before reconnecting, until the server sends a retry field.");
1827
+ writer.line(" * @property maximumDelay What the wait may grow to after reconnections in a row that fail.");
1828
+ writer.line(" */");
1829
+ emitConstructorClass(writer, "data class ReconnectPolicy", [
1830
+ "val maximumAttempts: Int = 5",
1831
+ "val reconnectionTime: kotlin.time.Duration = 3.seconds",
1832
+ "val maximumDelay: kotlin.time.Duration = 30.seconds"
1833
+ ]);
1749
1834
  if (usesMultipart) {
1750
1835
  writer.blank();
1751
1836
  emitConstructorClass(writer, "data class MultipartFile", [
@@ -1801,7 +1886,7 @@ const emitClient = (writer, config, partition, context, typesByOperation, regist
1801
1886
  }
1802
1887
  if (groups.length > 0) for (const group of groups) {
1803
1888
  writer.blank();
1804
- writer.line(`val ${escapeKeyword(group.propertyName)} = ${group.className}(client, baseUrl, json, ${contextFields.length > 0 ? "requestContextHeaders" : "emptyMap()"}, requestInterceptor, responseInterceptor, retry)`);
1889
+ writer.line(`val ${escapeKeyword(group.propertyName)} = ${group.className}(client, baseUrl, json, ${contextFields.length > 0 ? "requestContextHeaders" : "emptyMap()"}, requestInterceptor, responseInterceptor, retry, reconnect)`);
1805
1890
  }
1806
1891
  for (const method of flatMethods) {
1807
1892
  writer.blank();
@@ -1476,6 +1476,35 @@ const emitErrorBranches = (writer, method, context) => {
1476
1476
  }
1477
1477
  writer.line(`else -> throw ${failureRef}.Unexpected(statusCode = statusCode, data = data)`);
1478
1478
  };
1479
+ const IDEMPOTENT_METHODS = new Set([
1480
+ "GET",
1481
+ "HEAD",
1482
+ "OPTIONS",
1483
+ "PUT",
1484
+ "DELETE"
1485
+ ]);
1486
+ /**
1487
+ * Writes the reconnection an event stream reopens through and returns the argument handing it to `Kizuna.events`, or
1488
+ * nothing for a method a client may not resend.
1489
+ */
1490
+ const emitReopen = (writer, method, operationRef, status) => {
1491
+ if (!IDEMPOTENT_METHODS.has(method.method)) return "";
1492
+ closure(writer, "val reconnection = reconnect?.let { policy ->", () => {
1493
+ closure(writer, "Kizuna.Reconnection(policy) { lastEventId ->", () => {
1494
+ writer.line("val again = requestBuilder.build().newBuilder()");
1495
+ writer.line("if (lastEventId.isNotEmpty()) again.header(\"Last-Event-ID\", lastEventId)");
1496
+ writer.line("requestInterceptor?.invoke(again)");
1497
+ writer.line("val reopened = Kizuna.send(client, again.build(), retry)");
1498
+ writer.line("val eventStream = reopened.header(\"Content-Type\")?.lowercase()?.startsWith(\"text/event-stream\") == true");
1499
+ writer.block("when", () => {
1500
+ writer.line("reopened.code == 204 -> { reopened.close(); null }");
1501
+ writer.line(`reopened.code == ${status} && eventStream -> reopened`);
1502
+ writer.line(`else -> { reopened.close(); throw ${operationRef}.Failure.Unexpected(statusCode = reopened.code, data = ByteArray(0)) }`);
1503
+ });
1504
+ });
1505
+ });
1506
+ return ", reconnection";
1507
+ };
1479
1508
  const emitStreamMethodTail = (writer, method, context) => {
1480
1509
  const operationRef = `${context.clientName}.${method.operationName}`;
1481
1510
  const stream = method.stream;
@@ -1488,7 +1517,7 @@ const emitStreamMethodTail = (writer, method, context) => {
1488
1517
  else if (stream.events) {
1489
1518
  const events = stream.events;
1490
1519
  const elementType = streamElementType(method, context, "client");
1491
- closure(writer, `val body = Kizuna.events<${elementType}>(httpResponse) { event ->`, () => {
1520
+ closure(writer, `val body = Kizuna.events<${elementType}>(httpResponse${emitReopen(writer, method, operationRef, stream.status)}) { event ->`, () => {
1492
1521
  writer.block("when (event.event)", () => {
1493
1522
  for (const event of events) {
1494
1523
  const payloadType = resolveType(event.type, method.operationName, context);
@@ -1499,7 +1528,8 @@ const emitStreamMethodTail = (writer, method, context) => {
1499
1528
  });
1500
1529
  } else {
1501
1530
  const elementType = streamElementType(method, context, "client");
1502
- writer.line(`val body = Kizuna.events<${elementType}>(httpResponse) { event -> json.decodeFromString<${elementType}>(event.data) }`);
1531
+ const reconnection = emitReopen(writer, method, operationRef, stream.status);
1532
+ writer.line(`val body = Kizuna.events<${elementType}>(httpResponse${reconnection}) { event -> json.decodeFromString<${elementType}>(event.data) }`);
1503
1533
  }
1504
1534
  if (hasHeaders) {
1505
1535
  for (const field of successResponse.responseHeaders) writer.line(`val ${escapeKeyword(field.name)} = httpResponse.header(${stringLiteral(field.wireName)})`);
@@ -1545,7 +1575,7 @@ const emitSubClientMethod = (writer, method, context) => {
1545
1575
  };
1546
1576
  const emitSubClientClass = (writer, group, clientName, context) => {
1547
1577
  writer.blank();
1548
- writer.block(`class ${group.className}(private val client: OkHttpClient, private val baseUrl: String, private val json: Json, private val requestContextHeaders: Map<String, String>, private val requestInterceptor: (suspend (Request.Builder) -> Unit)?, private val responseInterceptor: (suspend (Request, Response) -> Unit)?, private val retry: ${clientName}.RetryPolicy?)`, () => {
1578
+ writer.block(`class ${group.className}(private val client: OkHttpClient, private val baseUrl: String, private val json: Json, private val requestContextHeaders: Map<String, String>, private val requestInterceptor: (suspend (Request.Builder) -> Unit)?, private val responseInterceptor: (suspend (Request, Response) -> Unit)?, private val retry: ${clientName}.RetryPolicy?, private val reconnect: ${clientName}.ReconnectPolicy?)`, () => {
1549
1579
  for (const method of group.methods) {
1550
1580
  writer.blank();
1551
1581
  emitSubClientMethod(writer, method, context);
@@ -1557,10 +1587,12 @@ const closure = (writer, opener, body) => {
1557
1587
  writer.indent(body);
1558
1588
  writer.line("}");
1559
1589
  };
1560
- const emitKizunaStreaming = (writer) => {
1590
+ const emitKizunaStreaming = (writer, clientName) => {
1561
1591
  writer.blank();
1562
1592
  writer.line("data class ServerSentEvent(val event: String?, val data: String, val id: String?, val retry: Int?)");
1563
1593
  writer.blank();
1594
+ writer.line(`class Reconnection(val policy: ${clientName}.ReconnectPolicy, val reopen: suspend (String) -> Response?)`);
1595
+ writer.blank();
1564
1596
  writer.block("private suspend fun readLine(source: okio.BufferedSource): String?", () => {
1565
1597
  writer.line("return kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { source.readUtf8Line() }");
1566
1598
  });
@@ -1587,44 +1619,83 @@ const emitKizunaStreaming = (writer) => {
1587
1619
  });
1588
1620
  });
1589
1621
  writer.blank();
1590
- writer.block("fun serverSentEvents(response: Response): Flow<ServerSentEvent> = flow", () => {
1591
- closure(writer, "response.use { open ->", () => {
1592
- writer.line("val source = open.body?.source() ?: return@use");
1593
- writer.line("var eventType = \"\"");
1594
- writer.line("val dataLines = mutableListOf<String>()");
1595
- writer.line("var lastEventId = \"\"");
1596
- writer.line("var retry: Int? = null");
1597
- writer.block("while (true)", () => {
1598
- writer.line("val line = readLine(source) ?: break");
1599
- writer.block("if (line.isEmpty())", () => {
1600
- writer.block("if (dataLines.isNotEmpty())", () => {
1601
- writer.line("emit(ServerSentEvent(eventType.ifEmpty { null }, dataLines.joinToString(\"\\n\"), lastEventId.ifEmpty { null }, retry))");
1602
- writer.line("dataLines.clear()");
1603
- writer.line("retry = null");
1622
+ writer.line("private val LINE_ENDINGS = okio.ByteString.of('\\r'.code.toByte(), '\\n'.code.toByte())");
1623
+ writer.blank();
1624
+ writer.block("private suspend fun readEventLine(source: okio.BufferedSource): String?", () => {
1625
+ closure(writer, "return kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {", () => {
1626
+ writer.line("val end = source.indexOfElement(LINE_ENDINGS)");
1627
+ writer.block("if (end == -1L)", () => {
1628
+ writer.line("if (source.exhausted()) null else source.readUtf8()");
1629
+ });
1630
+ writer.block("else", () => {
1631
+ writer.line("val line = source.readUtf8(end)");
1632
+ writer.line("val carriageReturn = source.readByte() == '\\r'.code.toByte()");
1633
+ writer.line("if (carriageReturn && source.request(1) && source.buffer[0] == '\\n'.code.toByte()) source.skip(1)");
1634
+ writer.line("line");
1635
+ });
1636
+ });
1637
+ });
1638
+ writer.blank();
1639
+ writer.block("fun serverSentEvents(response: Response, reconnect: Reconnection?): Flow<ServerSentEvent> = flow", () => {
1640
+ writer.line("var lastEventId = \"\"");
1641
+ writer.line("var reconnectionTime = reconnect?.policy?.reconnectionTime ?: 3.seconds");
1642
+ writer.line("var current: Response? = response");
1643
+ writer.line("var attempts = 0");
1644
+ writer.line("var failure: Throwable? = null");
1645
+ writer.block("while (true)", () => {
1646
+ closure(writer, "current?.use { open ->", () => {
1647
+ writer.line("val source = open.body?.source() ?: return@use");
1648
+ writer.line("var eventType = \"\"");
1649
+ writer.line("val dataLines = mutableListOf<String>()");
1650
+ writer.line("var pendingId = lastEventId");
1651
+ writer.line("var retry: Int? = null");
1652
+ writer.line("var firstLine = true");
1653
+ writer.block("while (true)", () => {
1654
+ writer.line("val received = try { readEventLine(source) } catch (error: java.io.IOException) { failure = error; null } ?: break");
1655
+ writer.line("val line = if (firstLine) received.removePrefix(\"\\uFEFF\") else received");
1656
+ writer.line("firstLine = false");
1657
+ writer.block("if (line.isEmpty())", () => {
1658
+ writer.line("lastEventId = pendingId");
1659
+ writer.block("if (dataLines.isNotEmpty())", () => {
1660
+ writer.line("emit(ServerSentEvent(eventType.ifEmpty { null }, dataLines.joinToString(\"\\n\"), lastEventId.ifEmpty { null }, retry))");
1661
+ writer.line("attempts = 0");
1662
+ writer.line("failure = null");
1663
+ writer.line("dataLines.clear()");
1664
+ writer.line("retry = null");
1665
+ });
1666
+ writer.line("eventType = \"\"");
1667
+ writer.line("continue");
1668
+ });
1669
+ writer.line("if (line.startsWith(\":\")) continue");
1670
+ writer.line("val separator = line.indexOf(':')");
1671
+ writer.line("val field = if (separator == -1) line else line.substring(0, separator)");
1672
+ writer.line("val value = (if (separator == -1) \"\" else line.substring(separator + 1)).removePrefix(\" \")");
1673
+ writer.block("when (field)", () => {
1674
+ writer.line("\"event\" -> eventType = value");
1675
+ writer.line("\"data\" -> dataLines.add(value)");
1676
+ writer.line("\"id\" -> if (!value.contains('\\u0000')) pendingId = value");
1677
+ writer.line("\"retry\" -> if (value.isNotEmpty() && value.all { character -> character in '0'..'9' }) value.toIntOrNull()?.let { milliseconds -> retry = milliseconds; reconnectionTime = minOf(milliseconds.milliseconds, 3_600_000.milliseconds) }");
1604
1678
  });
1605
- writer.line("eventType = \"\"");
1606
- writer.line("continue");
1607
- });
1608
- writer.line("if (line.startsWith(\":\")) continue");
1609
- writer.line("val separator = line.indexOf(':')");
1610
- writer.line("val field = if (separator == -1) line else line.substring(0, separator)");
1611
- writer.line("val value = (if (separator == -1) \"\" else line.substring(separator + 1)).removePrefix(\" \")");
1612
- writer.block("when (field)", () => {
1613
- writer.line("\"event\" -> eventType = value");
1614
- writer.line("\"data\" -> dataLines.add(value)");
1615
- writer.line("\"id\" -> if (!value.contains('\\u0000')) lastEventId = value");
1616
- writer.line("\"retry\" -> if (value.isNotEmpty() && value.all { it.isDigit() }) retry = value.toIntOrNull()");
1617
1679
  });
1618
1680
  });
1681
+ writer.block("if (reconnect == null || attempts >= reconnect.policy.maximumAttempts)", () => {
1682
+ writer.line("failure?.let { throw it }");
1683
+ writer.line("return@flow");
1684
+ });
1685
+ writer.line("attempts += 1");
1686
+ writer.line("val ceiling = minOf(reconnect.policy.maximumDelay, reconnectionTime * Math.pow(2.0, (attempts - 1).toDouble()))");
1687
+ writer.line("val extra = (ceiling - reconnectionTime).coerceAtLeast(kotlin.time.Duration.ZERO)");
1688
+ writer.line("kotlinx.coroutines.delay(reconnectionTime + extra * Math.random())");
1689
+ writer.line("current = try { reconnect.reopen(lastEventId) ?: return@flow } catch (error: java.io.IOException) { failure = error; null }");
1619
1690
  });
1620
1691
  });
1621
1692
  writer.blank();
1622
- writer.line("fun <Event : Any> events(response: Response, decode: (ServerSentEvent) -> Event?): Flow<Event> = serverSentEvents(response).mapNotNull(decode)");
1693
+ writer.line("fun <Event : Any> events(response: Response, reconnect: Reconnection? = null, decode: (ServerSentEvent) -> Event?): Flow<Event> = serverSentEvents(response, reconnect).mapNotNull(decode)");
1623
1694
  };
1624
1695
  const emitKizunaObject = (writer, options) => {
1625
1696
  writer.blank();
1626
1697
  writer.block("private object Kizuna", () => {
1627
- if (options.streaming) emitKizunaStreaming(writer);
1698
+ if (options.streaming) emitKizunaStreaming(writer, options.clientName);
1628
1699
  writer.block("fun resolveUrl(baseUrl: String, path: String): HttpUrl.Builder", () => {
1629
1700
  writer.line("val base = baseUrl.toHttpUrl()");
1630
1701
  writer.line("val trimmedPath = path.trimStart('/')");
@@ -1721,7 +1792,7 @@ const emitClient = (writer, config, partition, context, typesByOperation, regist
1721
1792
  const contextRequired = contextFields.some((field) => !hasDefault(field));
1722
1793
  const contextParam = contextFields.length > 0 ? `requestContext: RequestContext${contextRequired ? "" : " = RequestContext()"}, ` : "";
1723
1794
  writer.blank();
1724
- writer.block(`class ${clientName}(private val baseUrl: String, ${contextParam}private val client: OkHttpClient = OkHttpClient(), private val json: Json = Json { ignoreUnknownKeys = true }, private val requestInterceptor: (suspend (Request.Builder) -> Unit)? = null, private val responseInterceptor: (suspend (Request, Response) -> Unit)? = null, private val retry: RetryPolicy? = null)`, () => {
1795
+ writer.block(`class ${clientName}(private val baseUrl: String, ${contextParam}private val client: OkHttpClient = OkHttpClient(), private val json: Json = Json { ignoreUnknownKeys = true }, private val requestInterceptor: (suspend (Request.Builder) -> Unit)? = null, private val responseInterceptor: (suspend (Request, Response) -> Unit)? = null, private val retry: RetryPolicy? = null, private val reconnect: ReconnectPolicy? = null)`, () => {
1725
1796
  if (contextFields.length > 0) {
1726
1797
  writer.blank();
1727
1798
  writer.line("/** Values sent as headers on every request, from the api's request context. */");
@@ -1746,6 +1817,20 @@ const emitClient = (writer, config, partition, context, typesByOperation, regist
1746
1817
  "val initialDelay: kotlin.time.Duration = 500.milliseconds",
1747
1818
  "val maximumDelay: kotlin.time.Duration = 30.seconds"
1748
1819
  ]);
1820
+ writer.blank();
1821
+ writer.line("/**");
1822
+ writer.line(" * Reconnects an event stream on a GET, HEAD, OPTIONS, PUT or DELETE when its connection drops,");
1823
+ writer.line(" * sending the id of the last event as Last-Event-ID.");
1824
+ writer.line(" *");
1825
+ writer.line(" * @property maximumAttempts Reconnections in a row before the stream fails. An event received resets the count.");
1826
+ writer.line(" * @property reconnectionTime What to wait before reconnecting, until the server sends a retry field.");
1827
+ writer.line(" * @property maximumDelay What the wait may grow to after reconnections in a row that fail.");
1828
+ writer.line(" */");
1829
+ emitConstructorClass(writer, "data class ReconnectPolicy", [
1830
+ "val maximumAttempts: Int = 5",
1831
+ "val reconnectionTime: kotlin.time.Duration = 3.seconds",
1832
+ "val maximumDelay: kotlin.time.Duration = 30.seconds"
1833
+ ]);
1749
1834
  if (usesMultipart) {
1750
1835
  writer.blank();
1751
1836
  emitConstructorClass(writer, "data class MultipartFile", [
@@ -1801,7 +1886,7 @@ const emitClient = (writer, config, partition, context, typesByOperation, regist
1801
1886
  }
1802
1887
  if (groups.length > 0) for (const group of groups) {
1803
1888
  writer.blank();
1804
- writer.line(`val ${escapeKeyword(group.propertyName)} = ${group.className}(client, baseUrl, json, ${contextFields.length > 0 ? "requestContextHeaders" : "emptyMap()"}, requestInterceptor, responseInterceptor, retry)`);
1889
+ writer.line(`val ${escapeKeyword(group.propertyName)} = ${group.className}(client, baseUrl, json, ${contextFields.length > 0 ? "requestContextHeaders" : "emptyMap()"}, requestInterceptor, responseInterceptor, retry, reconnect)`);
1805
1890
  }
1806
1891
  for (const method of flatMethods) {
1807
1892
  writer.blank();
package/dist/index.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_generator = require("./generator-Bg4HlRb3.cjs");
2
+ const require_generator = require("./generator-Dft2DL-3.cjs");
3
3
  let kizunajs_generator = require("kizunajs/generator");
4
4
  let zod = require("zod");
5
5
  //#region src/client-target.ts
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { n as kotlinClientWalk, t as generateKotlinClient } from "./generator-D4GvH1-a.mjs";
1
+ import { n as kotlinClientWalk, t as generateKotlinClient } from "./generator-DHi8XSWb.mjs";
2
2
  import { defineClient } from "kizunajs/generator";
3
3
  import { z } from "zod";
4
4
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kizunajs/kotlin",
3
- "version": "2.0.0-beta.20",
3
+ "version": "2.0.0-beta.21",
4
4
  "description": "Kotlin client generation for Kizuna.js APIs",
5
5
  "keywords": [
6
6
  "kizunajs",
@@ -60,14 +60,14 @@
60
60
  "access": "public"
61
61
  },
62
62
  "dependencies": {
63
- "@kizunajs/cli": "2.0.0-beta.20"
63
+ "@kizunajs/cli": "2.0.0-beta.21"
64
64
  },
65
65
  "peerDependencies": {
66
- "kizunajs": "2.0.0-beta.20",
66
+ "kizunajs": "2.0.0-beta.21",
67
67
  "zod": "^4.0.0"
68
68
  },
69
69
  "devDependencies": {
70
- "kizunajs": "2.0.0-beta.20",
70
+ "kizunajs": "2.0.0-beta.21",
71
71
  "@types/node": "^22.9.0",
72
72
  "tsdown": "^0.21.0",
73
73
  "typescript": "^6.0.3",