Giter VIP home page Giter VIP logo

kustomexport's Introduction

KustomExport: a KSP generator of JS facade

Status: Experimental

At Deezer, we develop KMP libraries, this tool help us provide nicer API for Typescript since November 2021.

The generated code will change to support more features and/or stop supporting features provided by KotlinJS directly, so you may expect breaking changes when upgrading.

Motivation

Providing a nice Typescript API can sometimes be complex/verbose from a Kotlin Multiplatform Project.

A simple example:

// @JsExport // Cannot be annotated in Kotlin 1.6.10 and before
enum class StateEnum { IDLE, RUNNING }
@JsExport
data class SomeDataObject(
    val timestamp: Long,
    val state: StateEnum,
    val idList: List<String>
)

Results in Typescript:

export namespace sample.doc_examples {
    class SomeDataObject {
        constructor(timestamp: any/* kotlin.Long */, state: any/* sample.doc_examples.StateEnum */, idList: any/* kotlin.collections.List<string> */);
        get timestamp(): any/* kotlin.Long */;
        get state(): any/* sample.doc_examples.StateEnum */;
        get idList(): any/* kotlin.collections.List<string> */;
        component1(): any/* kotlin.Long */;
        component2(): any/* sample.doc_examples.StateEnum */;
        component3(): any/* kotlin.collections.List<string> */;
        copy(timestamp: any/* kotlin.Long */, state: any/* sample.doc_examples.StateEnum */, idList: any/* kotlin.collections.List<string> */): sample.doc_examples.SomeDataObject;
        toString(): string;
        hashCode(): number;
        equals(other: Nullable<any>): boolean;
    }
}
  • Long will not produce a number but a kotlin.Long behing a any (doc), but web developers usually use number to store timestamp.
  • Enums are not handled yet (KT-37916) and so exported as any
  • List could be used in a majority of cases if it was exported in Arrays
  • Lots of Kotlin methods like componentX, copy, hashCode or equals coming from data class are not really relevant or unusable (copy() should define all fields in typescript anyway)
  • comments in the Typescript generated code is helpful but only comment and doesn't fail build when type changes

There are some good reasons why it's not supported by KotlinJs right now, but it's not practical to provide a clean Typescript API.

KustomExport wants to be the bridge to provide a nice Typescript API until KotlinJS produces an equivalent export.

Technical approach

A manual way to approach this problem is to write a facade in Kotlin and only @JsExport the facade.

// jsMain/StateEnumJs.kt
// Export the enum in a class, so it's providing a real class in JS instead of 'any'
@JsExport
class StateEnumJs internal constructor(internal val stateEnum: StateEnum) {
    val name: String = stateEnum.name
}
fun StateEnumJs.import(): StateEnum = value
fun StateEnum.export(): StateEnumJs = Encoding(this)
// Object that exposes all possible values of the enum (note the 's')
@JsExport
object StateEnumsJs {
    val IDLE: StateEnumJs = StateEnumJs(StateEnum.IDLE)
    val RUNNING: StateEnumJs = StateEnumJs(StateEnum.RUNNING)
}
// And more methods like values(), valueOf()...
  • It doesn't scale easily: lots of boilerplate code to maintain.
  • New feature can be invisible when changes are non-breaking with the facade. A dangerous behaviour for library providers.
  • It requires calling import()/export() method in many places, leading to a "specific to web" code in your common code.

If you write similar facades yourself, this generator could help you avoid writing them manually. Please open issues with your needs!

Note that it's adding code to your existing codebase, so it will increase your JS bundle size.

Current status

The current project uses Typescript integration tests, feel free to check what's covered in Samples.

What is supported today:

  • Long to number (by using toLong/toDouble, so be careful with precision issues here!) (doc)
  • List<...> to Array<...> and it's ready to support more collections, please open a ticket with your needs. (doc)
  • enum classes (doc)
  • class / data class (equals/toString/componentX methods are removed) (doc)
  • functions and dynamic properties are wrapped (ex val rand: Long get() = Random.nextLong() will be wrapped and called again each time the exposed object is called in Typescript)
  • interfaces (doc)
  • sealed class
  • suspend methods with cooperative cancellation (via AbortController)
  • removing the namespace (optional) (doc)
  • mixing @JsExport with @KustomExport (experimental)

What is not supported yet:

  • generics (partially supported, you can generate a list of facades from a single generic class)

Feel free to open issues for more features!

You can have a look to the Samples to have a feel of how it can be used.

Setup

KustomExport use KSP (Kotlin Symbol Processor), and you need to install it in your build.gradle.kts

plugins {
    kotlin("multiplatform")
    id("com.google.devtools.ksp") version "1.6.21-1.0.5"
}

Then you need to define the dependency to the library

repositories {
    mavenCentral()
    maven(url = "https://raw.githubusercontent.com/Deezer/KustomExport/mvn-repo")
}

kotlin {
    // jvm(), js() and other platforms...
    sourceSets {
        val commonMain by getting {
            dependencies {
                implementation("deezer.kustomexport:lib:<version>")
                implementation("deezer.kustomexport:lib-coroutines:<version>")
            }
        }
    }
}

And finally enable the compiler with

kotlin { 
    // KMP configuration
}
dependencies {
    add("kspJs", "deezer.kustomexport:compiler:0.6.1")
}

Licence

/*
 * Copyright 2021 Deezer.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */

kustomexport's People

Contributors

antpa avatar christophsturm avatar glureau avatar grahamborland avatar marcosignoretto avatar shama avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar

kustomexport's Issues

Remove Exception support?

A previous experimentation has been pushed on Exceptions.

It was practical for our use case at the time but it's leading to more and more subtle issues that we don't want to support. Also it worked only when passing the exception in a method/property, but it cannot be wrapped when a throw is done from Typescript, meaning that a try/catch on the Kotlin will still have to deal with the Typescript Error and not the wrapped exception.

From our learnings, we think it's better to have explicit API with types that don't extends Exception. And if we need to pass it, we'll let KotlinJs deal with it and not trying to interfere here. But for that we need #19 and #17 to be fixed.

As a bonus, it should also reduce the bundle size.

Coroutines doesn't support immediate cancellation

    const abortController = new AbortController();
    const { signal: abortSignal } = abortController;
    var promise = longCompute(abortSignal); // Get a Promise from Kotlin
    abortController.abort(); // Won't work
    
    // Workaround: await just a bit so the promise Kotlin is actually created and listen the `onabort`
    await new Promise((resolve) => {
      setTimeout(resolve, 0);
    });
    abortController.abort(); // Works

The first call to abort() should work.

Support companion object methods

In some cases, we want to be able to provide companion object methods, for example to build the class. The class itself could have a private constructor, like in this example:

@JvmInline
internal value class DurationMs private constructor(internal val value: Long) {
    init {
        require(value >= 0)
    }

    operator fun plus(t: DurationMs): DurationMs {
        return from(value + t.value)
    }

    fun toSecond(): Int = (value / 1000).toInt()

    companion object {
        fun from(value: Long) = DurationMs(value)
    }
}

Enum : Use the given String parameter to fill the value

Currently we can define an enum like that

enum class Foo(val value: String) {
  ONE("one"), TWO("hey")
}

But once wrapped, there is no way to get the parameters. We don't want to support all super-powers of Kotlin enums right now. Using the given parameter if there is only one string, and returning the value in the facade could be enough.

Exported enum instance not guaranteed

Hi,

Feel free to rename this issue so it is clearer for you ๐Ÿ˜‰ .

With this Kotlin enum:

@KustomExport
public enum class Bar() {
    FIRST, SECOND
}

And this TypeScript code:

const bar = fooInstance.bar // Bar_FIRST
if (bar === Bar_FIRST) {
  console.log('First');
}

The enum instance returned on fooInstance is not guaranteed to be an exported instance of Bar (for example in this case there will be no console.log).

(Hi @glureau ๐Ÿ‘‹ )

Support mixing @JsExport and @KustomExport

Kotlin 1.6.20 brings the support of enum, so using @JsExport on simple enums could be good for performance. Right now the processor expects that all dependencies are also annotated with @KustomExport, but it doesn't check if the classes actually use it or not, leading to project not able to build.

Goal is to scan the dependencies annotations and be able to support JS Export automatically.

Using KustomExport types from external JS back in Kotlin

Hi! First, just wanted to say thanks for this library. It's made the TypeScript from Kotlin so much nicer to use. :)

I ran into an issue while using with external and have been looking into a fix but I'm fairly new to Kotlin so I could just be doing something wrong. I'm using the latest on the master branch as well as tried with 0.8.0 of KustomExport.

If I have a type exported from Kotlin with @KustomExport (e.g. class Item), create that type in JS (e.g. new Item()), and then send it back through Kotlin, accessing the properties from that object returns undefined.

I have this example Kotlin:

@KustomExport
class Item(
    val id: Long,
    val name: String
)

@KustomExport
class KotlinLibrary(private val js: JsLibrary) {
    fun getItem(): Item {
        return js.getItem()
    }
}

external class JsLibrary {
    fun getItem(): Item = definedExternally
}

and this JS:

export default class JsLibrary {
    getItem(): Item {
        return new Item(1, "Test")
    }
}
// ---
const js = new JsLibrary()
const kt = new KotlinLibrary(js)
const item = kt.getItem()
console.log(item.name) // undefined but should be "Test"

I've been attempting to add support for this use case but thought I'd open an issue just in case you are already familiar with this use case or know a way to support it. I've written a test for the use case here. The first four assertions pass but the others that interface with KustomExport do not pass.

It appears that returning an instance from JS, when it's ran through CommonItem.exportItem(), the getters that proxy to the internal common are no longer connected. I'm new to Kotlin though so that could be incorrect.

Thank you for your time!

`Map<>` is not supported as a data class property

A data class that has a map property with type arguments does not get translated correctly. For example:

@KustomExport
data class MapHolder(val map: Map<String, String>) 

Will result in:

2 type arguments expected for interface Map<K, out V>

Plugin-based architecture?

This project is super cool!

I'm curious if you'd explore a plugin-based architecture.

As an example, we have enums declared like:

enum class MyEnum(val value: String) {
  OPTION_A("option_a"),
  OPTION_B("option_b");
}

We'd love to write a custom rule that lets us export MyEnum as the underlying value (as a lightweight String), rather than a heavier class.

We also have other enum classes that use Ints.

Support Enum Companion

@KustomExport
enum class E {
    EnumValue;

    companion object {
        val static = 1
    }
}

generates

@JsExport
public class E internal constructor(
    internal val common: CommonE,
) {
    public val name: String = common.name
}

@JsExport
public fun E_values(): Array<E> = arrayOf(E_EnumValue, E_Companion)

@JsExport
public fun E_valueOf(name: String): E? {
    if (name == E_EnumValue.name)
        return E_EnumValue

    if (name == E_Companion.name)
        return E_Companion

    return null
}

public fun E.importE(): CommonE = common

public fun CommonE.exportE(): E = E_valueOf(this.name)!!

@JsExport
public val E_EnumValue: E = E(CommonE.EnumValue)

@JsExport
public val E_Companion: E = E(CommonE.Companion)

which is an error because Type mismatch: inferred type is E.Companion but E was expected in the last line. The incorrect assumption is made that the companion object is an enum value.

Workaround: Use JsExport instead of KustomExport

Default value is erased in generated code

Having the following code:

@JsExport
class Tester {
  fun test(f: String = "default value"): String {
    return f
  }
}

Will give this ts output:

class Tester {
    constructor();
    test(f?: string): string;
}

Preserving default value. However, using KustomExport on the same code:

@KustomExport
class Tester {
  fun test(f: String = "default value"): String {
    return f
  }
}

Will generate following kotlin code:

public fun test(f: String): String {
    val result = common.test(
        f = f,
    )
    return result
}

As a result, produced typescript definitions have mandatory parameter f:

class Tester {
    constructor();
    test(f: string): string;
}

Add better copy method to data classes

For Kotlin data classes, JsExport generates a copy that basically has the same signature as the Kotlin method, i.e. one parameter for each class property. The problem is that JS and TS do not support named arguments, so you always have to specify all parameters. The typical way to simulate this behavior in JS is to use single parameter functions which take in a single object and that object represents a map of parameter names to values.

It seems like a nice feature, if KustomExport could generate alternative copy methods for data classes that are represented by this type of method.

Generic types are erased

Hi!
I noticed an issue with exporting generic classes. For example, typescript map

@KustomExport
class Foo {
  fun bar(
    map: Map<String>,
  ) {}
}

Will generate following kotlin code:

public fun bar(map: Map): Unit {
    val result = common.bar(
        map = map,
    )
    return result
}

Erasing Map's type

Exceptions are actually duplicated

By default Kotlin generate exceptions in the js code but they are not visible in the Typescript code.

By exposing exceptions like CancellationException for Typescript, KustomExport actually generates a duplicated class in the js code. If passing one exception via a method is properly handled, if exception is thrown it's not wrapping the exception. So eventually we ends up with CancellationException and CancellationException_1 in the js code, and the instanceof is failing.

Proposed solution: use external + @JsExport instead of redefining those exception classes.

Handle value class

0.1.1 release actually consider value class as a class:

  • if you don't use the annotation on this class, other classes will expect a generated class with the name of the value class, but it doesn't exist
  • if you do use the annotation, as it considers it as a class, it'll not work, or generate boilerplate and that's not what we expect from a value class

I think we should generate a typealias for annotated value class, so that it's closer to the expected output of a value class.

Add `@file` support

JsExport supports @file which is convenient for files with lots of exported classes, to cut down on boilerplate.

Export enum as String/Int

Exporting enum to a class to be able to map additional properties and functions is increasing the JS bundle size. An alternative could be to export an enum as a string for example:

@KustomExport(exportMode = ExportMode.Enum.asString)
enum class Direction {
    NORTH, SOUTH, WEST, EAST
}

We could have a default mode to auto that could choose the mode asString or asInt if there is only one String/Int parameter, and choose the full wrapper when there is more than 1 constructor param or a method.

Full fledge wrapper
import sample._enum.Direction as CommonDirection // The annotated enum in commonMain

@JsExport
public class Direction internal constructor(
    internal val `value`: CommonDirection
) {
    public val name: String = value.name
}

public fun Direction.importDirection(): CommonDirection = value

public fun CommonDirection.exportDirection(): Direction = Direction(this)

@JsExport
public object Directions {
    public val NORTH: Direction = CommonDirection.NORTH.exportDirection()

    public val SOUTH: Direction = CommonDirection.SOUTH.exportDirection()

    public val WEST: Direction = CommonDirection.WEST.exportDirection()

    public val EAST: Direction = CommonDirection.EAST.exportDirection()
}
Export as String
import sample._enum.Direction as CommonDirection // The annotated enum in commonMain

typealias Direction = String

public fun Direction.importDirection(): CommonDirection = CommonDirection.valueOf(this)

public fun CommonDirection.exportDirection(): Direction = this.name

@JsExport
public const val Directions_NORTH: Direction = "NORTH"
@JsExport
public const val Directions_SOUTH: Direction = "SOUTH"
@JsExport
public const val Directions_WEST: Direction = "WEST"
@JsExport
public const val Directions_EAST: Direction = "EAST"

Bundle size (js) : 428 chars saved (no compressed) when exporting as strings (vs the full fledged export). A big part of the gain come from removing the object Directions and using (const) val instead, if we keep an object Dimensions the gain is only 14 chars. (So it's probably better to remove the object for the full fledged export, also const as no impact on bundle size.)

Limitations:

  • only available when there is no methods/properties (we may be able to use the 1st param instead of name when only one param is defined)
  • fun goTo(d: Direction) will be exported as goTo(direction: string); (as expected). It allows passing any string value, but a bad string will generate an IllegalStateException, possibly crashing the app.

Eventually there is an issue today with KSP/KotlinJsIr on multi-modules where external dependencies are not resolvable (WIP). With the current implementation, we expect a class not resolvable to be from another module, and it could be an issue with typealias resolution (not tested yet).

Also a little note about enums, Kotlin 1.6.20 should export enums without an additional layer... https://youtrack.jetbrains.com/issue/KT-37916

Annotating sealed class should export all child classes

If you annotate a sealed class with JsExport, it will export all of the child classes by default. KustomExport does not behave the same way. I found that:

@KustomExport
sealed class Foo {
    data class Bar(val numbers: List<Long>) : Foo()
}

Will fail with a compiler error. It seems the only way to export the child classes is to do:

@KustomExport
sealed class Foo

@KustomExport
data class Bar(val numbers: List<Long>) : Foo()

Sealed class

Exporting sealed class with KotlinJs make the code executable but not compliant with strict typescript rules. (The generated class is not abstract but with abstract fields for example, creating issues on typescript.)

https://youtrack.jetbrains.com/issue/KT-39193

We could generate an abstract class instead: the sealed is important for Kotlin side, but it has no class hierarchy limitation on the JS/TS word, so it's probably nothing more than an abstract class for the facade.

Dependency update tools can't detect new versions of KustomExport

Tools such as Renovate and the gradle-versions-plugin are unable to find version information for KustomExport.

$ ./gradlew dependencyUpdates --info

... 

The exception that is the cause of unresolved state: org.gradle.internal.resolve.ModuleVersionNotFoundException: Could not find any matches for deezer.kustomexport:compiler:+ as no versions of deezer.kustomexport:compiler are available.
Searched in the following locations:
  - https://dl.google.com/dl/android/maven2/deezer/kustomexport/compiler/maven-metadata.xml
  - https://repo.maven.apache.org/maven2/deezer/kustomexport/compiler/maven-metadata.xml
  - https://raw.githubusercontent.com/Deezer/KustomExport/mvn-repo/deezer/kustomexport/compiler/maven-metadata.xml
Required by:
    project :lib

The exception that is the cause of unresolved state: org.gradle.internal.resolve.ModuleVersionNotFoundException: Could not find any matches for deezer.kustomexport:lib:+ as no versions of deezer.kustomexport:lib are available.
Required by:
    project :lib

Normally these tools don't have a problem finding dependencies in custom Maven repos. Is there something missing or unusual about the way KustomExport builds are published?

Support inner classes

It seems like references to inner classes are just replaced by references to the outermost class instead, which of course breaks the code.

@KustomExport
data class A(val b: B) {
    data class B(val x: Int)
}

generates

@JsExport
public class A(
    b: CommonA,
) {
    internal lateinit var common: CommonA

    init {
        if (b != dynamicNull) {
            common = CommonA(
                b = b,
            )
        }
    }

    public val b: CommonA
        get() = common.b

    @Suppress("UNNECESSARY_SAFE_CALL")
    internal constructor(common: CommonA) : this(b = dynamicNull.unsafeCast<CommonA>()) {
        this.common = common
    }
}

public fun CommonA.exportA(): A = A(this)

public fun A.importA(): CommonA = this.common

Workaround: Extract the inner classes to top level classes

Support Kotlin 1.6.20

RC2 is available, after testing it quickly I noticed some weird stuff going on regarding data class wrapping, resulting in Cannot read properties of undefined kind of errors.

(It may be due to our trick with 2nd internal constructor to preserve the first ctor original via tricky use of dynamic.)

Sealed interface not supported

package ynab.slik.data.repository

import deezer.kustomexport.KustomExport

@KustomExport
sealed interface InvalidDataError

@KustomExport
class InvalidNumber(val number: Int) : InvalidDataError

@KustomExport
class InvalidName(val name: String) : InvalidDataError

This fails to compile:

> Task :lib:compileKotlinJs FAILED
e: file:///Users/graham/Work/YNAB/slik/lib/build/generated/ksp/js/jsMain/kotlin/ynab/slik/data/repository/js/InvalidDataError.kt:11:5 Inheritance of sealed classes or interfaces from different module is prohibited
e: file:///Users/graham/Work/YNAB/slik/lib/build/generated/ksp/js/jsMain/kotlin/ynab/slik/data/repository/js/InvalidDataError.kt:11:5 Inheritor of sealed class or interface declared in package ynab.slik.data.repository.js but it must be in package ynab.slik.data.repository where base class is declared

FAILURE: Build failed with an exception.

If I change the sealed interface to sealed class, then it works.

package ynab.slik.data.repository

import deezer.kustomexport.KustomExport

@KustomExport
sealed class InvalidDataError

@KustomExport
class InvalidNumber(val number: Int) : InvalidDataError()

@KustomExport
class InvalidName(val name: String) : InvalidDataError()

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.