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

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-DGtAlheu.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-oVr_SgXs.mjs";
3
3
  import { mkdirSync, writeFileSync } from "node:fs";
4
4
  import { dirname, resolve } from "node:path";
5
5
  import { parseArgs } from "node:util";
@@ -551,18 +551,23 @@ const buildRouteMethod = (routeKey, route, registry, deprecated, deprecationMess
551
551
  successResponses.push({
552
552
  status,
553
553
  type: stream.messageType ?? "Event",
554
- responseHeaders: headersSchema ? collectObjectFields(headersSchema, registry, `${baseHint}ResponseHeaders`) : []
554
+ responseHeaders: headersSchema ? collectObjectFields(headersSchema, registry, `${baseHint}ResponseHeaders`) : [],
555
+ isText: false
555
556
  });
556
557
  continue;
557
558
  }
558
- const typeExpression = mapType((0, kizunajs_generator.resolveResponseBody)(responseValue), registry, responseHint).expression;
559
+ const bodySchema = (0, kizunajs_generator.resolveResponseBody)(responseValue);
560
+ const responseContentType = (0, kizunajs_generator.resolveResponseContentType)(responseValue);
561
+ const isText = !(0, kizunajs_generator.isBinarySchema)(bodySchema) && responseContentType !== void 0 && !(0, kizunajs_generator.isJsonMediaType)(responseContentType);
562
+ const typeExpression = mapType(bodySchema, registry, responseHint).expression;
559
563
  if ((0, kizunajs_generator.isSuccessStatus)(status)) {
560
564
  const headersSchema = (0, kizunajs_generator.resolveResponseHeaders)(responseValue);
561
565
  const perStatusHeaderFields = headersSchema ? collectObjectFields(headersSchema, registry, `${baseHint}ResponseHeaders`) : [];
562
566
  successResponses.push({
563
567
  status,
564
568
  type: typeExpression,
565
- responseHeaders: perStatusHeaderFields
569
+ responseHeaders: perStatusHeaderFields,
570
+ isText
566
571
  });
567
572
  } else errorCases.push({
568
573
  caseName: (0, kizunajs_generator.statusToCamelCase)(status),
@@ -700,9 +705,12 @@ const KOTLIN_PRIMITIVE_TYPES = new Set([
700
705
  "ByteArray"
701
706
  ]);
702
707
  /**
703
- * How a response body is read. Raw bytes are the body already.
708
+ * How a response body is read. Raw bytes are the body already, and text is its decoded bytes.
704
709
  */
705
- const decodeBody = (resolved) => resolved === "ByteArray" ? "data" : `json.decodeFromString<${resolved}>(data.decodeToString())`;
710
+ const decodeBody = (resolved, isText = false) => {
711
+ if (resolved === "ByteArray") return "data";
712
+ return isText ? "data.decodeToString()" : `json.decodeFromString<${resolved}>(data.decodeToString())`;
713
+ };
706
714
  const resolveType = (typeName, currentOperation, context, scope = "client") => {
707
715
  const { operationTypeMap, namespaceName, clientName, fileLevelTypeNames } = context;
708
716
  const optional = typeName.endsWith("?");
@@ -1413,7 +1421,7 @@ const emitMethodBody = (writer, method, context) => {
1413
1421
  writer.indent(() => {
1414
1422
  const resolved = resolveType(successResponse.type, method.operationName, context);
1415
1423
  writer.block("try", () => {
1416
- writer.line(`val payload = ${decodeBody(resolved)}`);
1424
+ writer.line(`val payload = ${decodeBody(resolved, successResponse.isText)}`);
1417
1425
  const bodyExpr = isMultiSuccess ? `${operationRef}.Success.Status${successResponse.status}(payload)` : "payload";
1418
1426
  if (hasHeaders) {
1419
1427
  for (const field of successResponse.responseHeaders) writer.line(`val ${escapeKeyword(field.name)} = httpResponse.header(${stringLiteral(field.wireName)})`);
@@ -1476,6 +1484,35 @@ const emitErrorBranches = (writer, method, context) => {
1476
1484
  }
1477
1485
  writer.line(`else -> throw ${failureRef}.Unexpected(statusCode = statusCode, data = data)`);
1478
1486
  };
1487
+ const IDEMPOTENT_METHODS = new Set([
1488
+ "GET",
1489
+ "HEAD",
1490
+ "OPTIONS",
1491
+ "PUT",
1492
+ "DELETE"
1493
+ ]);
1494
+ /**
1495
+ * Writes the reconnection an event stream reopens through and returns the argument handing it to `Kizuna.events`, or
1496
+ * nothing for a method a client may not resend.
1497
+ */
1498
+ const emitReopen = (writer, method, operationRef, status) => {
1499
+ if (!IDEMPOTENT_METHODS.has(method.method)) return "";
1500
+ closure(writer, "val reconnection = reconnect?.let { policy ->", () => {
1501
+ closure(writer, "Kizuna.Reconnection(policy) { lastEventId ->", () => {
1502
+ writer.line("val again = requestBuilder.build().newBuilder()");
1503
+ writer.line("if (lastEventId.isNotEmpty()) again.header(\"Last-Event-ID\", lastEventId)");
1504
+ writer.line("requestInterceptor?.invoke(again)");
1505
+ writer.line("val reopened = Kizuna.send(client, again.build(), retry)");
1506
+ writer.line("val eventStream = reopened.header(\"Content-Type\")?.lowercase()?.startsWith(\"text/event-stream\") == true");
1507
+ writer.block("when", () => {
1508
+ writer.line("reopened.code == 204 -> { reopened.close(); null }");
1509
+ writer.line(`reopened.code == ${status} && eventStream -> reopened`);
1510
+ writer.line(`else -> { reopened.close(); throw ${operationRef}.Failure.Unexpected(statusCode = reopened.code, data = ByteArray(0)) }`);
1511
+ });
1512
+ });
1513
+ });
1514
+ return ", reconnection";
1515
+ };
1479
1516
  const emitStreamMethodTail = (writer, method, context) => {
1480
1517
  const operationRef = `${context.clientName}.${method.operationName}`;
1481
1518
  const stream = method.stream;
@@ -1488,7 +1525,7 @@ const emitStreamMethodTail = (writer, method, context) => {
1488
1525
  else if (stream.events) {
1489
1526
  const events = stream.events;
1490
1527
  const elementType = streamElementType(method, context, "client");
1491
- closure(writer, `val body = Kizuna.events<${elementType}>(httpResponse) { event ->`, () => {
1528
+ closure(writer, `val body = Kizuna.events<${elementType}>(httpResponse${emitReopen(writer, method, operationRef, stream.status)}) { event ->`, () => {
1492
1529
  writer.block("when (event.event)", () => {
1493
1530
  for (const event of events) {
1494
1531
  const payloadType = resolveType(event.type, method.operationName, context);
@@ -1499,7 +1536,8 @@ const emitStreamMethodTail = (writer, method, context) => {
1499
1536
  });
1500
1537
  } else {
1501
1538
  const elementType = streamElementType(method, context, "client");
1502
- writer.line(`val body = Kizuna.events<${elementType}>(httpResponse) { event -> json.decodeFromString<${elementType}>(event.data) }`);
1539
+ const reconnection = emitReopen(writer, method, operationRef, stream.status);
1540
+ writer.line(`val body = Kizuna.events<${elementType}>(httpResponse${reconnection}) { event -> json.decodeFromString<${elementType}>(event.data) }`);
1503
1541
  }
1504
1542
  if (hasHeaders) {
1505
1543
  for (const field of successResponse.responseHeaders) writer.line(`val ${escapeKeyword(field.name)} = httpResponse.header(${stringLiteral(field.wireName)})`);
@@ -1545,7 +1583,7 @@ const emitSubClientMethod = (writer, method, context) => {
1545
1583
  };
1546
1584
  const emitSubClientClass = (writer, group, clientName, context) => {
1547
1585
  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?)`, () => {
1586
+ 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
1587
  for (const method of group.methods) {
1550
1588
  writer.blank();
1551
1589
  emitSubClientMethod(writer, method, context);
@@ -1557,10 +1595,12 @@ const closure = (writer, opener, body) => {
1557
1595
  writer.indent(body);
1558
1596
  writer.line("}");
1559
1597
  };
1560
- const emitKizunaStreaming = (writer) => {
1598
+ const emitKizunaStreaming = (writer, clientName) => {
1561
1599
  writer.blank();
1562
1600
  writer.line("data class ServerSentEvent(val event: String?, val data: String, val id: String?, val retry: Int?)");
1563
1601
  writer.blank();
1602
+ writer.line(`class Reconnection(val policy: ${clientName}.ReconnectPolicy, val reopen: suspend (String) -> Response?)`);
1603
+ writer.blank();
1564
1604
  writer.block("private suspend fun readLine(source: okio.BufferedSource): String?", () => {
1565
1605
  writer.line("return kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { source.readUtf8Line() }");
1566
1606
  });
@@ -1587,44 +1627,83 @@ const emitKizunaStreaming = (writer) => {
1587
1627
  });
1588
1628
  });
1589
1629
  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");
1630
+ writer.line("private val LINE_ENDINGS = okio.ByteString.of('\\r'.code.toByte(), '\\n'.code.toByte())");
1631
+ writer.blank();
1632
+ writer.block("private suspend fun readEventLine(source: okio.BufferedSource): String?", () => {
1633
+ closure(writer, "return kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {", () => {
1634
+ writer.line("val end = source.indexOfElement(LINE_ENDINGS)");
1635
+ writer.block("if (end == -1L)", () => {
1636
+ writer.line("if (source.exhausted()) null else source.readUtf8()");
1637
+ });
1638
+ writer.block("else", () => {
1639
+ writer.line("val line = source.readUtf8(end)");
1640
+ writer.line("val carriageReturn = source.readByte() == '\\r'.code.toByte()");
1641
+ writer.line("if (carriageReturn && source.request(1) && source.buffer[0] == '\\n'.code.toByte()) source.skip(1)");
1642
+ writer.line("line");
1643
+ });
1644
+ });
1645
+ });
1646
+ writer.blank();
1647
+ writer.block("fun serverSentEvents(response: Response, reconnect: Reconnection?): Flow<ServerSentEvent> = flow", () => {
1648
+ writer.line("var lastEventId = \"\"");
1649
+ writer.line("var reconnectionTime = reconnect?.policy?.reconnectionTime ?: 3.seconds");
1650
+ writer.line("var current: Response? = response");
1651
+ writer.line("var attempts = 0");
1652
+ writer.line("var failure: Throwable? = null");
1653
+ writer.block("while (true)", () => {
1654
+ closure(writer, "current?.use { open ->", () => {
1655
+ writer.line("val source = open.body?.source() ?: return@use");
1656
+ writer.line("var eventType = \"\"");
1657
+ writer.line("val dataLines = mutableListOf<String>()");
1658
+ writer.line("var pendingId = lastEventId");
1659
+ writer.line("var retry: Int? = null");
1660
+ writer.line("var firstLine = true");
1661
+ writer.block("while (true)", () => {
1662
+ writer.line("val received = try { readEventLine(source) } catch (error: java.io.IOException) { failure = error; null } ?: break");
1663
+ writer.line("val line = if (firstLine) received.removePrefix(\"\\uFEFF\") else received");
1664
+ writer.line("firstLine = false");
1665
+ writer.block("if (line.isEmpty())", () => {
1666
+ writer.line("lastEventId = pendingId");
1667
+ writer.block("if (dataLines.isNotEmpty())", () => {
1668
+ writer.line("emit(ServerSentEvent(eventType.ifEmpty { null }, dataLines.joinToString(\"\\n\"), lastEventId.ifEmpty { null }, retry))");
1669
+ writer.line("attempts = 0");
1670
+ writer.line("failure = null");
1671
+ writer.line("dataLines.clear()");
1672
+ writer.line("retry = null");
1673
+ });
1674
+ writer.line("eventType = \"\"");
1675
+ writer.line("continue");
1676
+ });
1677
+ writer.line("if (line.startsWith(\":\")) continue");
1678
+ writer.line("val separator = line.indexOf(':')");
1679
+ writer.line("val field = if (separator == -1) line else line.substring(0, separator)");
1680
+ writer.line("val value = (if (separator == -1) \"\" else line.substring(separator + 1)).removePrefix(\" \")");
1681
+ writer.block("when (field)", () => {
1682
+ writer.line("\"event\" -> eventType = value");
1683
+ writer.line("\"data\" -> dataLines.add(value)");
1684
+ writer.line("\"id\" -> if (!value.contains('\\u0000')) pendingId = value");
1685
+ 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
1686
  });
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
1687
  });
1618
1688
  });
1689
+ writer.block("if (reconnect == null || attempts >= reconnect.policy.maximumAttempts)", () => {
1690
+ writer.line("failure?.let { throw it }");
1691
+ writer.line("return@flow");
1692
+ });
1693
+ writer.line("attempts += 1");
1694
+ writer.line("val ceiling = minOf(reconnect.policy.maximumDelay, reconnectionTime * Math.pow(2.0, (attempts - 1).toDouble()))");
1695
+ writer.line("val extra = (ceiling - reconnectionTime).coerceAtLeast(kotlin.time.Duration.ZERO)");
1696
+ writer.line("kotlinx.coroutines.delay(reconnectionTime + extra * Math.random())");
1697
+ writer.line("current = try { reconnect.reopen(lastEventId) ?: return@flow } catch (error: java.io.IOException) { failure = error; null }");
1619
1698
  });
1620
1699
  });
1621
1700
  writer.blank();
1622
- writer.line("fun <Event : Any> events(response: Response, decode: (ServerSentEvent) -> Event?): Flow<Event> = serverSentEvents(response).mapNotNull(decode)");
1701
+ writer.line("fun <Event : Any> events(response: Response, reconnect: Reconnection? = null, decode: (ServerSentEvent) -> Event?): Flow<Event> = serverSentEvents(response, reconnect).mapNotNull(decode)");
1623
1702
  };
1624
1703
  const emitKizunaObject = (writer, options) => {
1625
1704
  writer.blank();
1626
1705
  writer.block("private object Kizuna", () => {
1627
- if (options.streaming) emitKizunaStreaming(writer);
1706
+ if (options.streaming) emitKizunaStreaming(writer, options.clientName);
1628
1707
  writer.block("fun resolveUrl(baseUrl: String, path: String): HttpUrl.Builder", () => {
1629
1708
  writer.line("val base = baseUrl.toHttpUrl()");
1630
1709
  writer.line("val trimmedPath = path.trimStart('/')");
@@ -1721,7 +1800,7 @@ const emitClient = (writer, config, partition, context, typesByOperation, regist
1721
1800
  const contextRequired = contextFields.some((field) => !hasDefault(field));
1722
1801
  const contextParam = contextFields.length > 0 ? `requestContext: RequestContext${contextRequired ? "" : " = RequestContext()"}, ` : "";
1723
1802
  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)`, () => {
1803
+ 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
1804
  if (contextFields.length > 0) {
1726
1805
  writer.blank();
1727
1806
  writer.line("/** Values sent as headers on every request, from the api's request context. */");
@@ -1746,6 +1825,20 @@ const emitClient = (writer, config, partition, context, typesByOperation, regist
1746
1825
  "val initialDelay: kotlin.time.Duration = 500.milliseconds",
1747
1826
  "val maximumDelay: kotlin.time.Duration = 30.seconds"
1748
1827
  ]);
1828
+ writer.blank();
1829
+ writer.line("/**");
1830
+ writer.line(" * Reconnects an event stream on a GET, HEAD, OPTIONS, PUT or DELETE when its connection drops,");
1831
+ writer.line(" * sending the id of the last event as Last-Event-ID.");
1832
+ writer.line(" *");
1833
+ writer.line(" * @property maximumAttempts Reconnections in a row before the stream fails. An event received resets the count.");
1834
+ writer.line(" * @property reconnectionTime What to wait before reconnecting, until the server sends a retry field.");
1835
+ writer.line(" * @property maximumDelay What the wait may grow to after reconnections in a row that fail.");
1836
+ writer.line(" */");
1837
+ emitConstructorClass(writer, "data class ReconnectPolicy", [
1838
+ "val maximumAttempts: Int = 5",
1839
+ "val reconnectionTime: kotlin.time.Duration = 3.seconds",
1840
+ "val maximumDelay: kotlin.time.Duration = 30.seconds"
1841
+ ]);
1749
1842
  if (usesMultipart) {
1750
1843
  writer.blank();
1751
1844
  emitConstructorClass(writer, "data class MultipartFile", [
@@ -1801,7 +1894,7 @@ const emitClient = (writer, config, partition, context, typesByOperation, regist
1801
1894
  }
1802
1895
  if (groups.length > 0) for (const group of groups) {
1803
1896
  writer.blank();
1804
- writer.line(`val ${escapeKeyword(group.propertyName)} = ${group.className}(client, baseUrl, json, ${contextFields.length > 0 ? "requestContextHeaders" : "emptyMap()"}, requestInterceptor, responseInterceptor, retry)`);
1897
+ writer.line(`val ${escapeKeyword(group.propertyName)} = ${group.className}(client, baseUrl, json, ${contextFields.length > 0 ? "requestContextHeaders" : "emptyMap()"}, requestInterceptor, responseInterceptor, retry, reconnect)`);
1805
1898
  }
1806
1899
  for (const method of flatMethods) {
1807
1900
  writer.blank();
@@ -1,4 +1,4 @@
1
- import { isBinarySchema, isDiscriminatedUnionSchema, isFileSchema, isHintPrefix, isIntegerSchema, isNamedStream, isObjectSchema, isStreamResponse, isSuccessStatus, localTypeName, mergeHeaderFields, parsePath, readDef, readDefType, readDeprecation, readDiscriminatedUnion, readDiscriminatorStringLiteral, readMetaBrand, readMetaDescription, readMetaId, readObjectShape, resolveResponseBody, resolveResponseHeaders, routeStreams, sanitizeFieldName, sanitizeIdentifier, shortTypeName, soleStreamResponse, statusToCamelCase, streamMode, toCamelCase, toPascalCase, unwrapOptionalWrappers, walkApi } from "kizunajs/generator";
1
+ import { isBinarySchema, isDiscriminatedUnionSchema, isFileSchema, isHintPrefix, isIntegerSchema, isJsonMediaType, isNamedStream, isObjectSchema, isStreamResponse, isSuccessStatus, localTypeName, mergeHeaderFields, parsePath, readDef, readDefType, readDeprecation, readDiscriminatedUnion, readDiscriminatorStringLiteral, readMetaBrand, readMetaDescription, readMetaId, readObjectShape, resolveResponseBody, resolveResponseContentType, resolveResponseHeaders, routeStreams, sanitizeFieldName, sanitizeIdentifier, shortTypeName, soleStreamResponse, statusToCamelCase, streamMode, toCamelCase, toPascalCase, unwrapOptionalWrappers, walkApi } from "kizunajs/generator";
2
2
  //#region src/emit.ts
3
3
  var KotlinWriter = class {
4
4
  lines = [];
@@ -551,18 +551,23 @@ const buildRouteMethod = (routeKey, route, registry, deprecated, deprecationMess
551
551
  successResponses.push({
552
552
  status,
553
553
  type: stream.messageType ?? "Event",
554
- responseHeaders: headersSchema ? collectObjectFields(headersSchema, registry, `${baseHint}ResponseHeaders`) : []
554
+ responseHeaders: headersSchema ? collectObjectFields(headersSchema, registry, `${baseHint}ResponseHeaders`) : [],
555
+ isText: false
555
556
  });
556
557
  continue;
557
558
  }
558
- const typeExpression = mapType(resolveResponseBody(responseValue), registry, responseHint).expression;
559
+ const bodySchema = resolveResponseBody(responseValue);
560
+ const responseContentType = resolveResponseContentType(responseValue);
561
+ const isText = !isBinarySchema(bodySchema) && responseContentType !== void 0 && !isJsonMediaType(responseContentType);
562
+ const typeExpression = mapType(bodySchema, registry, responseHint).expression;
559
563
  if (isSuccessStatus(status)) {
560
564
  const headersSchema = resolveResponseHeaders(responseValue);
561
565
  const perStatusHeaderFields = headersSchema ? collectObjectFields(headersSchema, registry, `${baseHint}ResponseHeaders`) : [];
562
566
  successResponses.push({
563
567
  status,
564
568
  type: typeExpression,
565
- responseHeaders: perStatusHeaderFields
569
+ responseHeaders: perStatusHeaderFields,
570
+ isText
566
571
  });
567
572
  } else errorCases.push({
568
573
  caseName: statusToCamelCase(status),
@@ -700,9 +705,12 @@ const KOTLIN_PRIMITIVE_TYPES = new Set([
700
705
  "ByteArray"
701
706
  ]);
702
707
  /**
703
- * How a response body is read. Raw bytes are the body already.
708
+ * How a response body is read. Raw bytes are the body already, and text is its decoded bytes.
704
709
  */
705
- const decodeBody = (resolved) => resolved === "ByteArray" ? "data" : `json.decodeFromString<${resolved}>(data.decodeToString())`;
710
+ const decodeBody = (resolved, isText = false) => {
711
+ if (resolved === "ByteArray") return "data";
712
+ return isText ? "data.decodeToString()" : `json.decodeFromString<${resolved}>(data.decodeToString())`;
713
+ };
706
714
  const resolveType = (typeName, currentOperation, context, scope = "client") => {
707
715
  const { operationTypeMap, namespaceName, clientName, fileLevelTypeNames } = context;
708
716
  const optional = typeName.endsWith("?");
@@ -1413,7 +1421,7 @@ const emitMethodBody = (writer, method, context) => {
1413
1421
  writer.indent(() => {
1414
1422
  const resolved = resolveType(successResponse.type, method.operationName, context);
1415
1423
  writer.block("try", () => {
1416
- writer.line(`val payload = ${decodeBody(resolved)}`);
1424
+ writer.line(`val payload = ${decodeBody(resolved, successResponse.isText)}`);
1417
1425
  const bodyExpr = isMultiSuccess ? `${operationRef}.Success.Status${successResponse.status}(payload)` : "payload";
1418
1426
  if (hasHeaders) {
1419
1427
  for (const field of successResponse.responseHeaders) writer.line(`val ${escapeKeyword(field.name)} = httpResponse.header(${stringLiteral(field.wireName)})`);
@@ -1476,6 +1484,35 @@ const emitErrorBranches = (writer, method, context) => {
1476
1484
  }
1477
1485
  writer.line(`else -> throw ${failureRef}.Unexpected(statusCode = statusCode, data = data)`);
1478
1486
  };
1487
+ const IDEMPOTENT_METHODS = new Set([
1488
+ "GET",
1489
+ "HEAD",
1490
+ "OPTIONS",
1491
+ "PUT",
1492
+ "DELETE"
1493
+ ]);
1494
+ /**
1495
+ * Writes the reconnection an event stream reopens through and returns the argument handing it to `Kizuna.events`, or
1496
+ * nothing for a method a client may not resend.
1497
+ */
1498
+ const emitReopen = (writer, method, operationRef, status) => {
1499
+ if (!IDEMPOTENT_METHODS.has(method.method)) return "";
1500
+ closure(writer, "val reconnection = reconnect?.let { policy ->", () => {
1501
+ closure(writer, "Kizuna.Reconnection(policy) { lastEventId ->", () => {
1502
+ writer.line("val again = requestBuilder.build().newBuilder()");
1503
+ writer.line("if (lastEventId.isNotEmpty()) again.header(\"Last-Event-ID\", lastEventId)");
1504
+ writer.line("requestInterceptor?.invoke(again)");
1505
+ writer.line("val reopened = Kizuna.send(client, again.build(), retry)");
1506
+ writer.line("val eventStream = reopened.header(\"Content-Type\")?.lowercase()?.startsWith(\"text/event-stream\") == true");
1507
+ writer.block("when", () => {
1508
+ writer.line("reopened.code == 204 -> { reopened.close(); null }");
1509
+ writer.line(`reopened.code == ${status} && eventStream -> reopened`);
1510
+ writer.line(`else -> { reopened.close(); throw ${operationRef}.Failure.Unexpected(statusCode = reopened.code, data = ByteArray(0)) }`);
1511
+ });
1512
+ });
1513
+ });
1514
+ return ", reconnection";
1515
+ };
1479
1516
  const emitStreamMethodTail = (writer, method, context) => {
1480
1517
  const operationRef = `${context.clientName}.${method.operationName}`;
1481
1518
  const stream = method.stream;
@@ -1488,7 +1525,7 @@ const emitStreamMethodTail = (writer, method, context) => {
1488
1525
  else if (stream.events) {
1489
1526
  const events = stream.events;
1490
1527
  const elementType = streamElementType(method, context, "client");
1491
- closure(writer, `val body = Kizuna.events<${elementType}>(httpResponse) { event ->`, () => {
1528
+ closure(writer, `val body = Kizuna.events<${elementType}>(httpResponse${emitReopen(writer, method, operationRef, stream.status)}) { event ->`, () => {
1492
1529
  writer.block("when (event.event)", () => {
1493
1530
  for (const event of events) {
1494
1531
  const payloadType = resolveType(event.type, method.operationName, context);
@@ -1499,7 +1536,8 @@ const emitStreamMethodTail = (writer, method, context) => {
1499
1536
  });
1500
1537
  } else {
1501
1538
  const elementType = streamElementType(method, context, "client");
1502
- writer.line(`val body = Kizuna.events<${elementType}>(httpResponse) { event -> json.decodeFromString<${elementType}>(event.data) }`);
1539
+ const reconnection = emitReopen(writer, method, operationRef, stream.status);
1540
+ writer.line(`val body = Kizuna.events<${elementType}>(httpResponse${reconnection}) { event -> json.decodeFromString<${elementType}>(event.data) }`);
1503
1541
  }
1504
1542
  if (hasHeaders) {
1505
1543
  for (const field of successResponse.responseHeaders) writer.line(`val ${escapeKeyword(field.name)} = httpResponse.header(${stringLiteral(field.wireName)})`);
@@ -1545,7 +1583,7 @@ const emitSubClientMethod = (writer, method, context) => {
1545
1583
  };
1546
1584
  const emitSubClientClass = (writer, group, clientName, context) => {
1547
1585
  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?)`, () => {
1586
+ 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
1587
  for (const method of group.methods) {
1550
1588
  writer.blank();
1551
1589
  emitSubClientMethod(writer, method, context);
@@ -1557,10 +1595,12 @@ const closure = (writer, opener, body) => {
1557
1595
  writer.indent(body);
1558
1596
  writer.line("}");
1559
1597
  };
1560
- const emitKizunaStreaming = (writer) => {
1598
+ const emitKizunaStreaming = (writer, clientName) => {
1561
1599
  writer.blank();
1562
1600
  writer.line("data class ServerSentEvent(val event: String?, val data: String, val id: String?, val retry: Int?)");
1563
1601
  writer.blank();
1602
+ writer.line(`class Reconnection(val policy: ${clientName}.ReconnectPolicy, val reopen: suspend (String) -> Response?)`);
1603
+ writer.blank();
1564
1604
  writer.block("private suspend fun readLine(source: okio.BufferedSource): String?", () => {
1565
1605
  writer.line("return kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { source.readUtf8Line() }");
1566
1606
  });
@@ -1587,44 +1627,83 @@ const emitKizunaStreaming = (writer) => {
1587
1627
  });
1588
1628
  });
1589
1629
  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");
1630
+ writer.line("private val LINE_ENDINGS = okio.ByteString.of('\\r'.code.toByte(), '\\n'.code.toByte())");
1631
+ writer.blank();
1632
+ writer.block("private suspend fun readEventLine(source: okio.BufferedSource): String?", () => {
1633
+ closure(writer, "return kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {", () => {
1634
+ writer.line("val end = source.indexOfElement(LINE_ENDINGS)");
1635
+ writer.block("if (end == -1L)", () => {
1636
+ writer.line("if (source.exhausted()) null else source.readUtf8()");
1637
+ });
1638
+ writer.block("else", () => {
1639
+ writer.line("val line = source.readUtf8(end)");
1640
+ writer.line("val carriageReturn = source.readByte() == '\\r'.code.toByte()");
1641
+ writer.line("if (carriageReturn && source.request(1) && source.buffer[0] == '\\n'.code.toByte()) source.skip(1)");
1642
+ writer.line("line");
1643
+ });
1644
+ });
1645
+ });
1646
+ writer.blank();
1647
+ writer.block("fun serverSentEvents(response: Response, reconnect: Reconnection?): Flow<ServerSentEvent> = flow", () => {
1648
+ writer.line("var lastEventId = \"\"");
1649
+ writer.line("var reconnectionTime = reconnect?.policy?.reconnectionTime ?: 3.seconds");
1650
+ writer.line("var current: Response? = response");
1651
+ writer.line("var attempts = 0");
1652
+ writer.line("var failure: Throwable? = null");
1653
+ writer.block("while (true)", () => {
1654
+ closure(writer, "current?.use { open ->", () => {
1655
+ writer.line("val source = open.body?.source() ?: return@use");
1656
+ writer.line("var eventType = \"\"");
1657
+ writer.line("val dataLines = mutableListOf<String>()");
1658
+ writer.line("var pendingId = lastEventId");
1659
+ writer.line("var retry: Int? = null");
1660
+ writer.line("var firstLine = true");
1661
+ writer.block("while (true)", () => {
1662
+ writer.line("val received = try { readEventLine(source) } catch (error: java.io.IOException) { failure = error; null } ?: break");
1663
+ writer.line("val line = if (firstLine) received.removePrefix(\"\\uFEFF\") else received");
1664
+ writer.line("firstLine = false");
1665
+ writer.block("if (line.isEmpty())", () => {
1666
+ writer.line("lastEventId = pendingId");
1667
+ writer.block("if (dataLines.isNotEmpty())", () => {
1668
+ writer.line("emit(ServerSentEvent(eventType.ifEmpty { null }, dataLines.joinToString(\"\\n\"), lastEventId.ifEmpty { null }, retry))");
1669
+ writer.line("attempts = 0");
1670
+ writer.line("failure = null");
1671
+ writer.line("dataLines.clear()");
1672
+ writer.line("retry = null");
1673
+ });
1674
+ writer.line("eventType = \"\"");
1675
+ writer.line("continue");
1676
+ });
1677
+ writer.line("if (line.startsWith(\":\")) continue");
1678
+ writer.line("val separator = line.indexOf(':')");
1679
+ writer.line("val field = if (separator == -1) line else line.substring(0, separator)");
1680
+ writer.line("val value = (if (separator == -1) \"\" else line.substring(separator + 1)).removePrefix(\" \")");
1681
+ writer.block("when (field)", () => {
1682
+ writer.line("\"event\" -> eventType = value");
1683
+ writer.line("\"data\" -> dataLines.add(value)");
1684
+ writer.line("\"id\" -> if (!value.contains('\\u0000')) pendingId = value");
1685
+ 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
1686
  });
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
1687
  });
1618
1688
  });
1689
+ writer.block("if (reconnect == null || attempts >= reconnect.policy.maximumAttempts)", () => {
1690
+ writer.line("failure?.let { throw it }");
1691
+ writer.line("return@flow");
1692
+ });
1693
+ writer.line("attempts += 1");
1694
+ writer.line("val ceiling = minOf(reconnect.policy.maximumDelay, reconnectionTime * Math.pow(2.0, (attempts - 1).toDouble()))");
1695
+ writer.line("val extra = (ceiling - reconnectionTime).coerceAtLeast(kotlin.time.Duration.ZERO)");
1696
+ writer.line("kotlinx.coroutines.delay(reconnectionTime + extra * Math.random())");
1697
+ writer.line("current = try { reconnect.reopen(lastEventId) ?: return@flow } catch (error: java.io.IOException) { failure = error; null }");
1619
1698
  });
1620
1699
  });
1621
1700
  writer.blank();
1622
- writer.line("fun <Event : Any> events(response: Response, decode: (ServerSentEvent) -> Event?): Flow<Event> = serverSentEvents(response).mapNotNull(decode)");
1701
+ writer.line("fun <Event : Any> events(response: Response, reconnect: Reconnection? = null, decode: (ServerSentEvent) -> Event?): Flow<Event> = serverSentEvents(response, reconnect).mapNotNull(decode)");
1623
1702
  };
1624
1703
  const emitKizunaObject = (writer, options) => {
1625
1704
  writer.blank();
1626
1705
  writer.block("private object Kizuna", () => {
1627
- if (options.streaming) emitKizunaStreaming(writer);
1706
+ if (options.streaming) emitKizunaStreaming(writer, options.clientName);
1628
1707
  writer.block("fun resolveUrl(baseUrl: String, path: String): HttpUrl.Builder", () => {
1629
1708
  writer.line("val base = baseUrl.toHttpUrl()");
1630
1709
  writer.line("val trimmedPath = path.trimStart('/')");
@@ -1721,7 +1800,7 @@ const emitClient = (writer, config, partition, context, typesByOperation, regist
1721
1800
  const contextRequired = contextFields.some((field) => !hasDefault(field));
1722
1801
  const contextParam = contextFields.length > 0 ? `requestContext: RequestContext${contextRequired ? "" : " = RequestContext()"}, ` : "";
1723
1802
  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)`, () => {
1803
+ 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
1804
  if (contextFields.length > 0) {
1726
1805
  writer.blank();
1727
1806
  writer.line("/** Values sent as headers on every request, from the api's request context. */");
@@ -1746,6 +1825,20 @@ const emitClient = (writer, config, partition, context, typesByOperation, regist
1746
1825
  "val initialDelay: kotlin.time.Duration = 500.milliseconds",
1747
1826
  "val maximumDelay: kotlin.time.Duration = 30.seconds"
1748
1827
  ]);
1828
+ writer.blank();
1829
+ writer.line("/**");
1830
+ writer.line(" * Reconnects an event stream on a GET, HEAD, OPTIONS, PUT or DELETE when its connection drops,");
1831
+ writer.line(" * sending the id of the last event as Last-Event-ID.");
1832
+ writer.line(" *");
1833
+ writer.line(" * @property maximumAttempts Reconnections in a row before the stream fails. An event received resets the count.");
1834
+ writer.line(" * @property reconnectionTime What to wait before reconnecting, until the server sends a retry field.");
1835
+ writer.line(" * @property maximumDelay What the wait may grow to after reconnections in a row that fail.");
1836
+ writer.line(" */");
1837
+ emitConstructorClass(writer, "data class ReconnectPolicy", [
1838
+ "val maximumAttempts: Int = 5",
1839
+ "val reconnectionTime: kotlin.time.Duration = 3.seconds",
1840
+ "val maximumDelay: kotlin.time.Duration = 30.seconds"
1841
+ ]);
1749
1842
  if (usesMultipart) {
1750
1843
  writer.blank();
1751
1844
  emitConstructorClass(writer, "data class MultipartFile", [
@@ -1801,7 +1894,7 @@ const emitClient = (writer, config, partition, context, typesByOperation, regist
1801
1894
  }
1802
1895
  if (groups.length > 0) for (const group of groups) {
1803
1896
  writer.blank();
1804
- writer.line(`val ${escapeKeyword(group.propertyName)} = ${group.className}(client, baseUrl, json, ${contextFields.length > 0 ? "requestContextHeaders" : "emptyMap()"}, requestInterceptor, responseInterceptor, retry)`);
1897
+ writer.line(`val ${escapeKeyword(group.propertyName)} = ${group.className}(client, baseUrl, json, ${contextFields.length > 0 ? "requestContextHeaders" : "emptyMap()"}, requestInterceptor, responseInterceptor, retry, reconnect)`);
1805
1898
  }
1806
1899
  for (const method of flatMethods) {
1807
1900
  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-DGtAlheu.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-oVr_SgXs.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.22",
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.22"
64
64
  },
65
65
  "peerDependencies": {
66
- "kizunajs": "2.0.0-beta.20",
66
+ "kizunajs": "2.0.0-beta.22",
67
67
  "zod": "^4.0.0"
68
68
  },
69
69
  "devDependencies": {
70
- "kizunajs": "2.0.0-beta.20",
70
+ "kizunajs": "2.0.0-beta.22",
71
71
  "@types/node": "^22.9.0",
72
72
  "tsdown": "^0.21.0",
73
73
  "typescript": "^6.0.3",