Skip to content

Commit 99ad754

Browse files
🚀 Add GraphQL subscriptions support for wasmJs target (#6637)
* Initial working implementation of WebSocket engine for wasmJs platform * Improve WebSocket engine for wasmJs: refine exceptions, adjust helper functions, clarify binary data handling constraints, and add comprehensive documentation * refactor wasmJs websocket engine to handle binary data properly using ArrayBufferView and Uint8Array, improving binary message support and documentation * Only add wasm support to the `.websocket` package * Factor String.toWebSocketUrl() * Escape the stack frame * Add a test for JS websockets * Simplify code and increase timeout * Increase test runner timeout as well * unbreak production wasm * unbreak production wasm --------- Co-authored-by: Martin Bonnin <[email protected]>
1 parent e3f2e04 commit 99ad754

File tree

22 files changed

+2244
-144
lines changed

22 files changed

+2244
-144
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,4 @@ docs.json
4545

4646
# Local Netlify folder
4747
.netlify
48+
/ref-docs/

build-logic/build.gradle.kts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ dependencies {
1919
compileOnly(libs.dgp)
2020

2121
implementation(libs.okhttp)
22+
implementation(libs.ktor.server.netty)
23+
implementation(libs.ktor.server.cors)
24+
implementation(libs.ktor.server.websockets)
2225

2326
implementation(libs.kotlinx.benchmark)
2427
implementation(libs.dokka)

build-logic/gradle.properties

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ org.gradle.jvmargs=-Xmx8g
55
org.gradle.caching=true
66
# Enable the configuration cache
77
# org.gradle.unsafe.configuration-cache=true
8+
ksp.allow.all.target.configuration=false

build-logic/src/main/kotlin/Mpp.kt

Lines changed: 10 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
21
import org.gradle.api.Action
32
import org.gradle.api.Project
43
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
@@ -34,42 +33,27 @@ private val enableJs = System.getenv("APOLLO_JVM_ONLY")?.toBoolean()?.not() ?: t
3433
private val enableApple = System.getenv("APOLLO_JVM_ONLY")?.toBoolean()?.not() ?: true
3534

3635

37-
fun Project.configureMpp(
36+
fun defaultTargets(
3837
withJvm: Boolean,
3938
withJs: Boolean,
4039
withLinux: Boolean,
4140
withAndroid: Boolean,
4241
withWasm: Boolean,
4342
appleTargets: Collection<String>,
44-
browserTest: Boolean,
45-
) {
46-
val kotlinExtension = extensions.findByName("kotlin") as? KotlinMultiplatformExtension
47-
check(kotlinExtension != null) {
48-
"No multiplatform extension found"
49-
}
50-
kotlinExtension.apply {
43+
): KotlinMultiplatformExtension.() -> Unit {
44+
return {
5145
if (withJvm) {
5246
jvm()
5347
}
5448

5549
if (enableJs && withJs) {
5650
js(IR) {
57-
if (browserTest) {
58-
browser {
59-
testTask(Action {
60-
useKarma {
61-
useChromeHeadless()
62-
}
63-
})
64-
}
65-
} else {
66-
nodejs {
67-
testTask(Action {
68-
useMocha {
69-
// Override default 2s timeout
70-
timeout = "120s"
71-
}
72-
})
51+
nodejs {
52+
testTask {
53+
useMocha {
54+
// Override default 2s timeout
55+
timeout = "120s"
56+
}
7357
}
7458
}
7559
}
@@ -108,8 +92,6 @@ fun Project.configureMpp(
10892
}
10993
}
11094
}
111-
112-
configureSourceSetGraph()
11395
}
11496
}
11597

@@ -143,7 +125,7 @@ fun Project.configureMpp(
143125
* ```
144126
*/
145127
@OptIn(ExperimentalKotlinGradlePluginApi::class)
146-
private fun KotlinMultiplatformExtension.configureSourceSetGraph() {
128+
internal fun KotlinMultiplatformExtension.configureSourceSetGraph() {
147129
applyDefaultHierarchyTemplate {
148130
group("common") {
149131
group("filesystem") {

build-logic/src/main/kotlin/api.kt

Lines changed: 51 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,7 @@ fun Project.version(): String {
2727
fun Project.apolloLibrary(
2828
namespace: String,
2929
jvmTarget: Int? = null,
30-
withJs: Boolean = true,
31-
withLinux: Boolean = true,
32-
withApple: Boolean = true,
33-
withJvm: Boolean = true,
34-
withWasm: Boolean = true,
30+
defaultTargets: (KotlinMultiplatformExtension.() -> Unit),
3531
androidOptions: AndroidOptions? = null,
3632
publish: Boolean = true,
3733
kotlinCompilerOptions: KotlinCompilerOptions = KotlinCompilerOptions(),
@@ -69,15 +65,11 @@ fun Project.apolloLibrary(
6965
}
7066

7167
if (extensions.findByName("kotlin") is KotlinMultiplatformExtension) {
72-
configureMpp(
73-
withJvm = withJvm,
74-
withJs = withJs,
75-
browserTest = false,
76-
withLinux = withLinux,
77-
appleTargets = if (!withApple) emptySet() else allAppleTargets,
78-
withAndroid = extensions.findByName("android") != null,
79-
withWasm = withWasm
80-
)
68+
val kotlinExtension = extensions.findByName("kotlin") as? KotlinMultiplatformExtension
69+
if (kotlinExtension != null) {
70+
kotlinExtension.defaultTargets()
71+
kotlinExtension.configureSourceSetGraph()
72+
}
8173
}
8274

8375
configureTesting()
@@ -105,12 +97,52 @@ fun Project.apolloLibrary(
10597
}
10698
}
10799

100+
fun Project.apolloLibrary(
101+
namespace: String,
102+
jvmTarget: Int? = null,
103+
withJs: Boolean = true,
104+
withLinux: Boolean = true,
105+
withApple: Boolean = true,
106+
withJvm: Boolean = true,
107+
withWasm: Boolean = true,
108+
androidOptions: AndroidOptions? = null,
109+
publish: Boolean = true,
110+
kotlinCompilerOptions: KotlinCompilerOptions = KotlinCompilerOptions(),
111+
) {
112+
val defaultTargets = defaultTargets(
113+
withJvm = withJvm,
114+
withJs = withJs,
115+
withLinux = withLinux,
116+
appleTargets = if (!withApple) emptySet() else allAppleTargets,
117+
withAndroid = androidOptions != null,
118+
withWasm = withWasm
119+
)
120+
121+
apolloLibrary(
122+
namespace,
123+
jvmTarget,
124+
defaultTargets,
125+
androidOptions,
126+
publish,
127+
kotlinCompilerOptions
128+
)
129+
}
130+
108131
fun Project.apolloTest(
109132
withJs: Boolean = true,
110133
withJvm: Boolean = true,
111134
appleTargets: Set<String> = setOf(hostTarget),
112-
browserTest: Boolean = false,
113135
kotlinCompilerOptions: KotlinCompilerOptions = KotlinCompilerOptions(),
136+
) {
137+
apolloTest(
138+
kotlinCompilerOptions,
139+
defaultTargets(withJvm = withJvm, withJs = withJs, withLinux = false, withAndroid = false, withWasm = false, appleTargets = appleTargets),
140+
)
141+
}
142+
143+
fun Project.apolloTest(
144+
kotlinCompilerOptions: KotlinCompilerOptions = KotlinCompilerOptions(),
145+
block: KotlinMultiplatformExtension.() -> Unit,
114146
) {
115147
commonSetup()
116148
configureJavaAndKotlinCompilers(
@@ -123,16 +155,10 @@ fun Project.apolloTest(
123155
)
124156
)
125157

126-
if (extensions.findByName("kotlin") is KotlinMultiplatformExtension) {
127-
configureMpp(
128-
withJvm = withJvm,
129-
withJs = withJs,
130-
browserTest = browserTest,
131-
withLinux = false,
132-
withAndroid = false,
133-
appleTargets = appleTargets,
134-
withWasm = false
135-
)
158+
val kotlinExtension = extensions.findByName("kotlin") as? KotlinMultiplatformExtension
159+
if (kotlinExtension != null) {
160+
kotlinExtension.block()
161+
kotlinExtension.configureSourceSetGraph()
136162
}
137163
configureTesting()
138164
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package websocket_server
2+
3+
import io.ktor.server.application.*
4+
import io.ktor.server.engine.*
5+
import io.ktor.server.netty.*
6+
import io.ktor.server.plugins.cors.routing.*
7+
import io.ktor.server.routing.routing
8+
import io.ktor.server.websocket.WebSockets
9+
import io.ktor.server.websocket.webSocket
10+
import io.ktor.websocket.CloseReason
11+
import io.ktor.websocket.Frame
12+
import io.ktor.websocket.close
13+
import io.ktor.websocket.readText
14+
import io.ktor.websocket.send
15+
import kotlinx.coroutines.cancel
16+
import kotlinx.coroutines.coroutineScope
17+
import kotlinx.io.files.Path
18+
import org.gradle.api.services.BuildService
19+
import org.gradle.api.services.BuildServiceParameters
20+
import java.lang.AutoCloseable
21+
22+
private val port = 18923
23+
24+
private fun server(): EmbeddedServer<NettyApplicationEngine, NettyApplicationEngine.Configuration> {
25+
return embeddedServer(Netty, port = port) {
26+
install(CORS) {
27+
/**
28+
* This is very permissive. It's only for tests but feel free to restrict if needed.
29+
*/
30+
anyMethod()
31+
anyHost()
32+
allowHeaders { true }
33+
allowNonSimpleContentTypes = true
34+
}
35+
install(WebSockets)
36+
routing {
37+
webSocket("/echo") {
38+
for (frame in incoming) {
39+
if (frame is Frame.Text && frame.readText() == "bye") {
40+
close(CloseReason(4400, "bye"))
41+
} else {
42+
if (frame is Frame.Text) {
43+
send(frame.readText())
44+
} else {
45+
send(frame.data)
46+
}
47+
}
48+
}
49+
}
50+
}
51+
}
52+
}
53+
54+
abstract class ServerBuildService : BuildService<BuildServiceParameters.None>, AutoCloseable {
55+
private var server: EmbeddedServer<NettyApplicationEngine, NettyApplicationEngine.Configuration>? = null
56+
private var refcount = 0
57+
58+
@Synchronized
59+
fun startServer() {
60+
if (refcount == 0) {
61+
println("Starting echo server")
62+
server = server().start(wait = false)
63+
}
64+
refcount++
65+
}
66+
67+
fun stopServer() {
68+
check(refcount > 0) {
69+
"Server not started"
70+
}
71+
refcount--
72+
if (refcount == 0) {
73+
close()
74+
}
75+
}
76+
77+
@Synchronized
78+
override fun close() {
79+
if (server != null) {
80+
println("Stopping echo server")
81+
server!!.stop(1000, 1000)
82+
server = null
83+
refcount = 0
84+
}
85+
}
86+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package websocket_server
2+
3+
import org.gradle.api.DefaultTask
4+
import org.gradle.api.provider.Property
5+
import org.gradle.api.services.ServiceReference
6+
import org.gradle.api.tasks.TaskAction
7+
8+
9+
abstract class StartServerTask: DefaultTask() {
10+
@get:ServiceReference
11+
abstract val buildService: Property<ServerBuildService>
12+
13+
@TaskAction
14+
fun taskAction() {
15+
buildService.get().startServer()
16+
}
17+
}
18+
19+
abstract class StopServerTask: DefaultTask() {
20+
@get:ServiceReference
21+
abstract val buildService: Property<ServerBuildService>
22+
23+
@TaskAction
24+
fun taskAction() {
25+
buildService.get().stopServer()
26+
}
27+
}
28+
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package websocket_server
2+
3+
import org.gradle.api.Project
4+
import org.gradle.api.tasks.testing.AbstractTestTask
5+
import org.jetbrains.kotlin.gradle.targets.js.testing.KotlinJsTest
6+
import org.jetbrains.kotlin.gradle.targets.js.webpack.KotlinWebpack
7+
import org.jetbrains.kotlin.gradle.tasks.KotlinTest
8+
9+
fun Project.configureWebSocketServer() {
10+
val startServer = tasks.register("startServer", StartServerTask::class.java)
11+
val stopServer = tasks.register("stopServer", StartServerTask::class.java)
12+
13+
gradle.sharedServices.registerIfAbsent("websocketServer", ServerBuildService::class.java)
14+
tasks.withType(AbstractTestTask::class.java).configureEach {
15+
dependsOn(startServer)
16+
finalizedBy(stopServer)
17+
}
18+
// For development
19+
tasks.withType(KotlinWebpack::class.java).configureEach {
20+
dependsOn(startServer)
21+
finalizedBy(stopServer)
22+
}
23+
}

gradle/libraries.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,9 @@ ktor-client-darwin = { group = "io.ktor", name = "ktor-client-darwin", version.r
153153
ktor-client-js = { group = "io.ktor", name = "ktor-client-js", version.ref = "ktor" }
154154
ktor-client-websockets = { group = "io.ktor", name = "ktor-client-websockets", version.ref = "ktor" }
155155
ktor-server-core = { group = "io.ktor", name = "ktor-server-core", version.ref = "ktor" }
156+
ktor-server-cors = { group = "io.ktor", name = "ktor-server-cors", version.ref = "ktor" }
156157
ktor-server-cio = { group = "io.ktor", name = "ktor-server-cio", version.ref = "ktor" }
158+
ktor-server-netty = { group = "io.ktor", name = "ktor-server-netty", version.ref = "ktor" }
157159
ktor-server-websockets = { group = "io.ktor", name = "ktor-server-websockets", version.ref = "ktor" }
158160
ktor-network = { module = "io.ktor:ktor-network", version.ref = "ktor" }
159161
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }

libraries/apollo-runtime/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
12
import org.jetbrains.kotlin.gradle.plugin.KotlinDependencyHandler
3+
import websocket_server.configureWebSocketServer
24

35
plugins {
46
id("org.jetbrains.kotlin.multiplatform")

0 commit comments

Comments
 (0)