Skip to content

Commit

Permalink
Adds ktor sample from ktor-samples repo
Browse files Browse the repository at this point in the history
  • Loading branch information
franciscoengenheiro committed Mar 9, 2024
1 parent d479cf7 commit c22bd95
Show file tree
Hide file tree
Showing 16 changed files with 3,640 additions and 48 deletions.
4 changes: 3 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
id("root.publication")
// trick: for the same plugin versions in all sub-modules
alias(libs.plugins.androidLibrary) apply false
alias(libs.plugins.kotlinMultiplatform) apply false
}
}
4 changes: 4 additions & 0 deletions convention-plugins/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,8 @@ plugins {

dependencies {
implementation(libs.nexus.publish)
}

kotlin {
jvmToolchain(17)
}
4 changes: 2 additions & 2 deletions convention-plugins/settings.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
pluginManagement {
repositories {
google()
gradlePluginPortal()
mavenCentral()
gradlePluginPortal()
google()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ nexusPublishing {
// Configure maven central repository
// https://github.com/gradle-nexus/publish-plugin#publishing-to-maven-central-via-sonatype-ossrh
repositories {
sonatype { //only for users registered in Sonatype after 24 Feb 2021
sonatype { // only for users registered in Sonatype after 24 Feb 2021
nexusUrl.set(uri("https://s01.oss.sonatype.org/service/local/"))
snapshotRepositoryUrl.set(uri("https://s01.oss.sonatype.org/content/repositories/snapshots/"))
}
Expand Down
2,890 changes: 2,890 additions & 0 deletions kotlin-js-store/yarn.lock

Large diffs are not rendered by default.

95 changes: 79 additions & 16 deletions ktor/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,24 +1,87 @@
import org.jetbrains.kotlin.gradle.DeprecatedTargetPresetApi
import org.jetbrains.kotlin.gradle.InternalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalDistributionDsl

buildscript {
repositories {
mavenCentral()
}
dependencies {
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.21")
}
}

repositories {
mavenCentral()
}

plugins {
id("io.ktor.plugin") version "2.3.8"
kotlin("jvm")
id("kotlin-multiplatform")
}

application {
mainClass.set("com.example.ApplicationKt")
kotlin {
@OptIn(DeprecatedTargetPresetApi::class, InternalKotlinGradlePluginApi::class)
targets {
js("frontend", IR) {
browser {
testTask { enabled = false }

val isDevelopment: Boolean = project.ext.has("development")
applicationDefaultJvmArgs = listOf("-Dio.ktor.development=$isDevelopment")
@OptIn(ExperimentalDistributionDsl::class)
distribution {
directory = file("$projectDir/src/backendMain/resources/web")
}
binaries.executable()
}
}
jvm("backend")
}

sourceSets.forEach {
it.dependencies {
implementation(project.dependencies.enforcedPlatform("io.ktor:ktor-bom:2.3.9"))
}
}

sourceSets {
val backendMain by getting {
dependencies {
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.21")
implementation("io.ktor:ktor-server-netty")
implementation("io.ktor:ktor-server-websockets")
implementation("io.ktor:ktor-server-call-logging")
implementation("io.ktor:ktor-server-default-headers")
implementation("io.ktor:ktor-server-sessions")
implementation("ch.qos.logback:logback-classic:1.4.6")
}
}

val backendTest by getting {
dependencies {
implementation("io.ktor:ktor-server-test-host")
implementation("io.ktor:ktor-client-websockets")
implementation("org.jetbrains.kotlin:kotlin-test")
}
}

val frontendMain by getting {
dependencies {
implementation("org.jetbrains.kotlin:kotlin-stdlib-js")
implementation("io.ktor:ktor-client-websockets")
implementation("io.ktor:ktor-client-js")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core-js:1.6.4")
}
}
}
}

repositories {
mavenCentral()
tasks.register<JavaExec>("run") {
dependsOn("frontendBrowserDistribution")
dependsOn("backendMainClasses")
mainClass.set("backendMain.ChatApplicationKt")
// classpath(configurations.getByName("backendRuntimeClasspath").plus("./build/libs/ktor-backend-0.0.1.jar"))
args = emptyList()
}

dependencies {
implementation("io.ktor:ktor-server-core-jvm")
implementation("io.ktor:ktor-server-netty-jvm")
implementation("ch.qos.logback:logback-classic:1.5.3")
testImplementation("io.ktor:ktor-server-tests-jvm")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:1.9.23")
implementation(kotlin("stdlib-jdk8"))
}
tasks.named("frontendBrowserProductionWebpack") {
mustRunAfter(":ktor:backendProcessResources")
}
2 changes: 2 additions & 0 deletions ktor/src/backendMain/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
resources/web/ktor.js
resources/web/ktor.js.map
174 changes: 174 additions & 0 deletions ktor/src/backendMain/kotlin/ChatApplication.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.http.content.*
import io.ktor.server.netty.*
import io.ktor.server.plugins.callloging.*
import io.ktor.server.plugins.defaultheaders.*
import io.ktor.server.routing.*
import io.ktor.server.sessions.*
import io.ktor.server.websocket.*
import io.ktor.util.*
import io.ktor.websocket.*
import kotlinx.coroutines.channels.*
import java.time.*

/**
* An entry point of the application.
*
* Notice that the fully qualified name of this function is `io.ktor.samples.chat.backend.ChatApplicationKt.main`.
* For top level functions, the class name containing the method in the JVM is FileNameKt.
*
* The `Application.main` part is Kotlin idiomatic that specifies that the main method is
* an extension of the [Application] class, and thus can be accessed like a normal member `myapplication.main()`.
*/
fun main() {
embeddedServer(Netty, port = 8080) {
ChatApplication().apply { main() }
}.start(wait = true)
}

fun Application.main() {
ChatApplication().apply { main() }
}

/**
* In this case, we have a class holding our application state so it is not global and can be tested easier.
*/
class ChatApplication {
/**
* This class handles the logic of a [ChatServer].
* With the standard handlers [ChatServer.memberJoin] or [ChatServer.memberLeft] and operations like
* sending messages to everyone or to specific people connected to the server.
*/
private val server = ChatServer()
// TODO(shouldn't server be injected?)

/**
* This is the main method of application in this class.
*/
fun Application.main() {
/**
* First, we install the plugins we need.
* They are bound to the whole application
* since this method has an implicit [Application] receiver that supports the [install] method.
*/
// This adds Date and Server headers to each response, and would allow you to configure
// additional headers served to each response.
install(DefaultHeaders)
// This uses the logger to log every call (request/response)
install(CallLogging)
// This installs the WebSockets plugin to be able to establish a bidirectional configuration
// between the server and the client
install(WebSockets) {
pingPeriod = Duration.ofMinutes(1)
}
// This enables the use of sessions to keep information between requests/refreshes of the browser.
install(Sessions) {
cookie<ChatSession>("SESSION")
}

// This adds an interceptor that will create a specific session in each request if no session is available already.
intercept(ApplicationCallPipeline.Plugins) {
if (call.sessions.get<ChatSession>() == null) {
call.sessions.set(ChatSession(generateNonce()))
}
}

/**
* Now we are going to define routes to handle specific methods + URLs for this application.
*/
routing {

// Defines a websocket `/ws` route that allows a protocol upgrade to convert a HTTP request/response request
// into a bidirectional packetized connection.
webSocket("/ws") { // this: WebSocketSession ->

// First of all we get the session.
val session = call.sessions.get<ChatSession>()

// We check that we actually have a session. We should always have one,
// since we have defined an interceptor before to set one.
if (session == null) {
close(CloseReason(CloseReason.Codes.VIOLATED_POLICY, "No session"))
return@webSocket
}

// We notify that a member joined by calling the server handler [memberJoin].
// This allows associating the session ID to a specific WebSocket connection.
server.memberJoin(session.id, this)

try {
// We start receiving messages (frames).
// Since this is a coroutine, it is suspended until receiving frames.
// Once the connection is closed, this consumeEach will finish and the code will continue.
incoming.consumeEach { frame ->
// Frames can be [Text], [Binary], [Ping], [Pong], [Close].
// We are only interested in textual messages, so we filter it.
if (frame is Frame.Text) {
// Now it is time to process the text sent from the user.
// At this point, we have context about this connection,
// the session, the text and the server.
// So we have everything we need.
receivedMessage(session.id, frame.readText())
}
}
} finally {
// Either if there was an error, or if the connection was closed gracefully,
// we notified the server that the member had left.
server.memberLeft(session.id, this)
}
}

// This defines a block of static resources for the '/' path (since no path is specified and we start at '/')
static {
// This marks index.html from the 'web' folder in resources as the default file to serve.
defaultResource("index.html", "web")
// This serves files from the 'web' folder in the application resources.
resources("web")
}

}
}

/**
* A chat session is identified by a unique nonce ID. This nonce comes from a secure random source.
*/
data class ChatSession(val id: String)

/**
* We received a message. Let's process it.
*/
private suspend fun receivedMessage(id: String, command: String) {
// We are going to handle commands (text starting with '/') and normal messages
when {
// The command `who` responds the user about all the member names connected to the user.
command.startsWith("/who") -> server.who(id)
// The command `user` allows the user to set its name.
command.startsWith("/user") -> {
// We strip the command part to get the rest of the parameters.
// In this case the only parameter is the user's newName.
val newName = command.removePrefix("/user").trim()
// We verify that it is a valid name (in terms of length) to prevent abusing
when {
newName.isEmpty() -> server.sendTo(id, "server::help", "/user [newName]")
newName.length > 50 -> server.sendTo(
id,
"server::help",
"new name is too long: 50 characters limit"
)
else -> server.memberRenamed(id, newName)
}
}
// The command 'help' allows users to get a list of available commands.
command.startsWith("/help") -> server.help(id)
// If no commands are matched at this point, we notify about it.
command.startsWith("/") -> server.sendTo(
id,
"server::help",
"Unknown command ${command.takeWhile { !it.isWhitespace() }}"
)
// Handle a normal message.
else -> server.message(id, command)
}
}
}
Loading

0 comments on commit c22bd95

Please # to comment.