Websocket server supported

This commit is contained in:
2026-01-04 00:33:42 +08:00
parent b6d35a2e72
commit 8a1635ca86
34 files changed
+262 -25

No files matched your search

+2
View File
@@ -2,3 +2,5 @@
.kotlin/
build/
.gradle/
/core/.dev.vars
/core/wrangler.toml
+2 -2
View File
@@ -26,8 +26,8 @@ repositories {
```kotlin
// to get latest version of this lib, go to
// https://next.pkg.rtast.cn/#/releases/cn/rtast/kotlin-cfworker/kotlin-cloudflare-worker/
implementation("cn.rtast.kotlin-cfworker:kotlin-cloudflare-worker:1.0.2")
// https://next.pkg.rtast.cn/#/releases/cn/rtast/kotlin-cfworker/core/
implementation("cn.rtast.kotlin-cfworker:core:1.0.2")
```
## Run app
+9 -14
View File
@@ -1,29 +1,23 @@
plugins {
kotlin("multiplatform") version "2.2.21"
kotlin("multiplatform") version "2.2.21" apply false
id("maven-publish")
}
allprojects {
group = "cn.rtast.kotlin-cfworker"
version = "1.0.2"
repositories {
mavenCentral()
}
kotlin {
explicitApi()
js(IR) { nodejs { binaries.executable() } }
sourceSets {
jsMain.dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
}
commonTest.dependencies {
implementation(kotlin("test"))
}
}
}
subprojects {
apply(plugin = "org.jetbrains.kotlin.multiplatform")
apply(plugin = "maven-publish")
publishing {
repositories {
@@ -35,3 +29,4 @@ publishing {
}
}
}
}
+67
View File
@@ -0,0 +1,67 @@
kotlin {
explicitApi()
js(IR) { nodejs { binaries.executable() } }
sourceSets {
jsMain.dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
}
commonTest.dependencies {
implementation(kotlin("test"))
}
}
}
val wranglerRunDir: Provider<Directory> = layout.buildDirectory.dir("wrangler-run")
tasks.register<Copy>("prepareWranglerRun") {
group = "wrangler"
dependsOn("compileDevelopmentExecutableKotlinJs")
val buildOutputDir = layout.buildDirectory.dir("compileSync/js/main/developmentExecutable/kotlin")
from(buildOutputDir)
into(wranglerRunDir)
}
val wranglerDev by tasks.registering(Exec::class) {
group = "wrangler"
workingDir = layout.buildDirectory.dir("wrangler-run").get().asFile.apply { mkdirs() }
doFirst {
val sourceDir = project.layout.projectDirectory
mapOf(
sourceDir.file("wrangler.toml").asFile to File(workingDir, "wrangler.toml"),
sourceDir.file(".dev.vars").asFile to File(workingDir, ".dev.vars"),
).forEach { (s, d) -> s.copyTo(d, overwrite = true) }
}
commandLine(
if (System.getProperty("os.name").lowercase().contains("windows")) listOf(
"cmd", "/c", "wrangler dev --port 7071"
)
else listOf("sh", "-c", "wrangler dev --port 7071")
)
standardInput = System.`in`
isIgnoreExitValue = false
}
val wranglerDeployDir: Provider<Directory> = layout.buildDirectory.dir("wrangler-deploy")
.apply { get().asFile.deleteRecursively() }
val prepareProductionDeploy by tasks.registering(Copy::class) {
group = "wrangler"
dependsOn("compileProductionExecutableKotlinJs")
val buildOutputDir = layout.buildDirectory.dir("compileSync/js/main/productionExecutable/kotlin")
from(buildOutputDir) { exclude("*.map") }
into(wranglerDeployDir)
from(layout.projectDirectory.file("wrangler.toml"))
into(wranglerDeployDir)
}
val wranglerDeploy by tasks.registering(Exec::class) {
group = "wrangler"
dependsOn(prepareProductionDeploy)
workingDir = layout.buildDirectory.dir("wrangler-deploy").get().asFile.apply { mkdirs() }
commandLine(
if (System.getProperty("os.name").lowercase().contains("windows")) listOf("cmd", "/c", "wrangler deploy")
else listOf("sh", "-c", "wrangler deploy")
)
standardInput = System.`in`
}
@@ -20,9 +20,12 @@ import cn.rtast.cfworker.route.type.AbstractRoute
import cn.rtast.cfworker.route.type.RegexRoute
import cn.rtast.cfworker.route.type.StringRoute
import cn.rtast.cfworker.util.decodeBase64String
import cn.rtast.cfworker.websocket.WebsocketEventHandler
import cn.rtast.cfworker.websocket.WebsocketRoute
import org.w3c.dom.url.URL
import org.w3c.fetch.Request
import org.w3c.fetch.Response
import org.w3c.fetch.ResponseInit
/**
* Kotlin cloudflare worker logic entrypoint class
@@ -31,12 +34,21 @@ public class WorkerApplication(
public val corsConfig: CORSConfig = CORSConfig(),
) {
internal val routes: MutableList<AbstractRoute> = mutableListOf()
internal val websocketRoutes: MutableList<WebsocketRoute> = mutableListOf()
public suspend fun handle(request: Request): Response {
val url = URL(request.url)
val path = url.pathname
val method = HttpMethod.fromString(request.method) ?: HttpMethod.GET
if (method == HttpMethod.OPTIONS && corsConfig.enabled) return respondEmpty()
if (request.headers.get("upgrade") != null) {
val route = websocketRoutes.firstOrNull { r ->
r.stringPath?.let { it == url.pathname } ?: r.regexPath?.matches(url.pathname) ?: false
} ?: return Response("Not Found", ResponseInit(404))
val handler = WebsocketEventHandler(request = request)
route.block(handler)
return handler.handle()
}
val matchedRoutes = routes.filter {
when (it) {
is StringRoute -> it.path == path
@@ -15,6 +15,8 @@ package cn.rtast.cfworker
import cn.rtast.cfworker.response.respondText
import cn.rtast.cfworker.route.route
import cn.rtast.cfworker.websocket.readText
import cn.rtast.cfworker.websocket.webSocket
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.promise
@@ -39,6 +41,15 @@ public fun handleRequest(request: Request): Promise<Response> = GlobalScope.prom
route("/") {
respondText("Hello kotlin cloudflare worker")
}
webSocket("/ws") {
onMessage {
println(it.readText())
}
onClose {
}
}
}
return@promise server.handle(request)
}
@@ -0,0 +1,94 @@
/*
* Copyright © 2026 RTAkland
* Author: RTAkland
* Date: 2026/1/3
*/
@file:OptIn(DelicateCoroutinesApi::class)
package cn.rtast.cfworker.websocket
import cn.rtast.cfworker.util.toByteArray
import cn.rtast.cfworker.websocket.response.respondSwitchingProtocol
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.promise
import org.khronos.webgl.ArrayBuffer
import org.khronos.webgl.Uint8Array
import org.w3c.dom.CloseEvent
import org.w3c.dom.MessageEvent
import org.w3c.dom.WebSocket
import org.w3c.dom.events.Event
import org.w3c.fetch.Request
import org.w3c.fetch.Response
import org.w3c.fetch.ResponseInit
import org.w3c.files.Blob
public class WebsocketEventHandler(public val request: Request) {
private var onMessageBlock: (suspend (MessageEvent) -> Unit)? = null
private var onCloseBlock: (suspend (CloseEvent) -> Unit?)? = null
private var onOpenBlock: (suspend (Event) -> Unit)? = null
private var onErrorBlock: (suspend (Event) -> Unit)? = null
private var requireUpgradeHeaderBlock: (suspend (Request) -> Response)? =
{ Response("Expected Upgrade: websocket", ResponseInit(426)) }
public val clients: MutableList<WebSocket> = mutableListOf()
public fun upgradeHeaderRequired(block: suspend (Request) -> Response) {
requireUpgradeHeaderBlock = block
}
public fun onMessage(block: suspend (MessageEvent) -> Unit) {
onMessageBlock = block
}
public fun onClose(block: suspend (CloseEvent) -> Unit?) {
onCloseBlock = block
}
public fun onOpen(block: suspend (Event) -> Unit) {
onOpenBlock = block
}
public fun onError(block: suspend (Event) -> Unit) {
onErrorBlock = block
}
public fun close(code: Short = 1000, reason: String? = null): Unit =
this.clients.forEach { it.close(code, reason ?: "Closed") }
internal suspend fun handle(): Response {
val upgradeHeader = request.headers.get("upgrade")
if (upgradeHeader == null || upgradeHeader != "websocket")
return requireUpgradeHeaderBlock!!.invoke(request)
val pair = js("new WebSocketPair()")
val client = pair[0]
val server = pair[1]
server.accept()
clients.add(server)
server.addEventListener("message", { e: MessageEvent -> GlobalScope.promise { onMessageBlock?.invoke(e) } })
server.addEventListener("error", { e: Event -> GlobalScope.promise { onErrorBlock?.invoke(e) } })
server.addEventListener("open", { e: Event -> GlobalScope.promise { onErrorBlock?.invoke(e) } })
server.addEventListener("close", { e: CloseEvent ->
GlobalScope.promise {
onCloseBlock?.invoke(e)
clients.remove(client)
}
})
return respondSwitchingProtocol(client)
}
}
public typealias WebsocketHandler = suspend WebsocketEventHandler.() -> Unit
public fun MessageEvent.readText(): String =
this.data.unsafeCast<String>()
public fun MessageEvent.readByteArray(): ByteArray =
this.data.unsafeCast<ArrayBuffer>().toByteArray()
public fun MessageEvent.readBlob(): Blob =
this.data.unsafeCast<Blob>()
public fun MessageEvent.readUint8Array(): Uint8Array =
this.data.unsafeCast<Uint8Array>()
@@ -0,0 +1,26 @@
/*
* Copyright © 2026 RTAkland
* Author: RTAkland
* Date: 2026/1/3
*/
package cn.rtast.cfworker.websocket
import cn.rtast.cfworker.WorkerApplication
internal data class WebsocketRoute(
val stringPath: String? = null,
val regexPath: Regex? = null,
val block: WebsocketHandler,
)
public fun WorkerApplication.webSocket(
path: Regex,
block: WebsocketHandler,
): Unit = run { websocketRoutes.add(WebsocketRoute(regexPath = path, block = block)) }
public fun WorkerApplication.webSocket(
path: String,
block: WebsocketHandler,
): Unit = run { websocketRoutes.add(WebsocketRoute(stringPath = path, block = block)) }
@@ -0,0 +1,17 @@
/*
* Copyright © 2026 RTAkland
* Author: RTAkland
* Date: 2026/1/3
*/
package cn.rtast.cfworker.websocket.response
import org.w3c.fetch.Response
/**
* Respond switching protocol
* Websocket
*/
internal fun respondSwitchingProtocol(client: dynamic): Response =
Response(null, js("{ status: 101, webSocket: client }"))
File renamed without changes.
+6
View File
@@ -0,0 +1,6 @@
name = "kotlin-cloudflare-worker" # any name you want
account_id = "<your account id>"
workers_dev = false
preview_urls = false
compatibility_date = "2022-08-11"
main = "kotlin-cloudflare-worker.js" # Set this value same as `outputModuleName`
+5
View File
@@ -0,0 +1,5 @@
kotlin {
js(IR) {
nodejs { binaries.executable() }
}
}
+2
View File
@@ -1 +1,3 @@
include(":core")
include(":serialization")
rootProject.name = "kotlin-cloudflare-worker"