diff --git a/.gitignore b/.gitignore index a64a68f..6f89599 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,18 @@ -/dist +.cargo-ok +.DS_Store +.gradle +.idea +.kotlin **/*.rs.bk -Cargo.lock +/.wrangler +/build/ +/dist +/index.js bin/ +build +Cargo.lock +kotlin-js-store +node_modules/ pkg/ wasm-pack.log worker/ -node_modules/ -.cargo-ok -/index.js -/build/ -.idea -.gradle -.DS_Store -kotlin-js-store \ No newline at end of file diff --git a/README.md b/README.md index d30f247..7939ce5 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,23 @@ # Kotlin hello world for Cloudflare Workers -Your Kotlin code in [main.kt](https://github.com/cloudflare/kotlin-worker-hello-world/blob/master/src/main/kotlin/main.kt), running on Cloudflare Workers +Your Kotlin code in [Application.kt](worker-app/src/jsMain/kotlin/Application.kt), running on Cloudflare Workers. -In addition to [Wrangler v2.x](https://github.com/cloudflare/wrangler2) you will need to install Kotlin, including a JDK and support for Gradle projects. The easiest way to do this is using the free Community Edition of [IntelliJ IDEA](https://kotlinlang.org/docs/tutorials/jvm-get-started.html). +You will need to install Kotlin, including a JDK and support for Gradle projects. The easiest way to do this is using the free Community Edition of [IntelliJ IDEA](https://kotlinlang.org/docs/tutorials/jvm-get-started.html). ## Wrangler - -Configure the [wrangler.toml](wrangler.toml) by filling in the `account_id` from the Workers pages of your Cloudflare Dashboard. +You will need to install wrangler, e.g. `npm install wrangler` Further documentation for Wrangler can be found [here](https://developers.cloudflare.com/workers/tooling/wrangler). ## Gradle - After setting up Kotlin per the linked instructions above, ``` -./gradlew :compileProductionExecutableKotlinJs +./gradlew assemble ``` -That will compile your code and package it into a JavaScript executable, after which you can run `wrangler publish` to push it to Cloudflare. +That will compile your code and package it into a JavaScript executable, after which you can run `wrangler dev` to start a localhost server for testing. -``` -wrangler publish build/js/packages/kotlin-worker-hello-world/kotlin/kotlin-worker-hello-world.js -``` +To publish to Cloudflare, run `wrangler publish` For more information on interop between Kotlin and Javascript, see the [Kotlin docs](https://kotlinlang.org/docs/reference/js-interop.html). Regarding coroutines, see [this issue and workaround](https://github.com/cloudflare/kotlin-worker-hello-world/issues/2) diff --git a/build.gradle.kts b/build.gradle.kts index e31f80e..6fd6571 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,18 +1,23 @@ +import org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsRootExtension +import org.jetbrains.kotlin.gradle.targets.js.yarn.YarnRootExtension + plugins { - kotlin("js") version "1.7.10" + alias(libs.plugins.kotlin.multiplatform) apply false } -group = "org.example" -version = "1.0-SNAPSHOT" +rootProject.plugins.withType(org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsRootPlugin::class.java) { + rootProject.the().versions.webpackCli.version = libs.versions.webpack.cli.get() +} -repositories { - mavenCentral() +rootProject.plugins.withType { + rootProject.the().apply { + download = project.property("CLOUDFLARE_IS_INSTALL_NODE").toString().toBoolean() + nodeVersion = libs.versions.node.get() + } } -kotlin { - js(IR) { - nodejs { - } - binaries.executable() +rootProject.plugins.withType { + rootProject.the().apply { + download = project.property("CLOUDFLARE_IS_INSTALL_YARN").toString().toBoolean() } } diff --git a/gradle.properties b/gradle.properties index ac01301..7c7a2b4 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,2 +1,13 @@ -kotlin.code.style=official -kotlin.js.ir.output.granularity=whole-program \ No newline at end of file +org.gradle.caching=true +org.gradle.configuration-cache=true +org.gradle.configureondemand=true +org.gradle.kotlin.dsl.allWarningsAsErrors=true +org.gradle.parallel=true +org.gradle.vfs.watch=true + +kotlin.build.report.output=file + +# Configures whether Node and Yarn are installed by the Kotlin build scripts +# Can be disabled on CI or an environment where these are known to be pre-installed +CLOUDFLARE_IS_INSTALL_NODE=true +CLOUDFLARE_IS_INSTALL_YARN=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..7543f5b --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,15 @@ +[versions] +kotlin = "1.9.22" +kotlinx-coroutines = "1.7.3" +miniflare = "3.20231218.4" +node = "20.11.0" +webpack-cli = "5.1.4" + +[libraries] +kotlin-gradle = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } +kotlinx-coroutines-debug = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-debug", version.ref = "kotlinx-coroutines" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } + +[plugins] +kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index f3d88b1..d64cd49 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ae04661..db8c3ba 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,8 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip +distributionSha256Sum=9d926787066a081739e8200858338b4a69e837c3a821a33aca9db09dd4a41026 +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 2fe81a7..1aa94a4 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ -#!/usr/bin/env sh +#!/bin/sh # -# Copyright 2015 the original author or authors. +# Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -17,78 +17,111 @@ # ############################################################################## -## -## Gradle start up script for UN*X -## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # Attempt to set APP_HOME + # Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -97,87 +130,120 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac fi -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java -if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) fi - i=`expr $i + 1` + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg done - case $i in - 0) set -- ;; - 1) set -- "$args0" ;; - 2) set -- "$args0" "$args1" ;; - 3) set -- "$args0" "$args1" "$args2" ;; - 4) set -- "$args0" "$args1" "$args2" "$args3" ;; - 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=`save "$@"` -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index 24467a1..6689b85 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -14,7 +14,7 @@ @rem limitations under the License. @rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,10 +25,14 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" @@ -37,7 +41,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto init +if %ERRORLEVEL% equ 0 goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. @@ -51,7 +55,7 @@ goto fail set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe -if exist "%JAVA_EXE%" goto init +if exist "%JAVA_EXE%" goto execute echo. echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% @@ -61,38 +65,26 @@ echo location of your Java installation. goto fail -:init -@rem Get command-line arguments, handling Windows variants - -if not "%OS%" == "Windows_NT" goto win9xME_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* - :execute @rem Setup the command line set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal diff --git a/miniflare-lib/build.gradle.kts b/miniflare-lib/build.gradle.kts new file mode 100644 index 0000000..9a78427 --- /dev/null +++ b/miniflare-lib/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + alias(libs.plugins.kotlin.multiplatform) +} + +kotlin { + js(IR) { + nodejs() + } + sourceSets { + getByName("jsMain") { + dependencies { + implementation(npm("miniflare", libs.versions.miniflare.get())) + } + } + getByName("jsTest") { + dependencies { + implementation(kotlin("test")) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.coroutines.test) + } + } + } +} diff --git a/miniflare-lib/src/jsMain/kotlin/com/cloudflare/miniflare/Miniflare.kt b/miniflare-lib/src/jsMain/kotlin/com/cloudflare/miniflare/Miniflare.kt new file mode 100644 index 0000000..1a49354 --- /dev/null +++ b/miniflare-lib/src/jsMain/kotlin/com/cloudflare/miniflare/Miniflare.kt @@ -0,0 +1,19 @@ +@file:JsModule("miniflare") + +package com.cloudflare.miniflare + +import org.w3c.fetch.RequestInit +import org.w3c.fetch.Response +import kotlin.js.Promise + +// https://github.com/cloudflare/miniflare/blob/v3.20231016.0/packages/miniflare/README.md#class-miniflare +external class Miniflare( + @Suppress("UnusedPrivateProperty") miniflareOptions: dynamic +) { + fun dispatchFetch( + url: String, + init: RequestInit? + ): Promise + + fun dispose(): Promise +} diff --git a/miniflare-lib/src/jsMain/kotlin/com/cloudflare/miniflare/MiniflareExt.kt b/miniflare-lib/src/jsMain/kotlin/com/cloudflare/miniflare/MiniflareExt.kt new file mode 100644 index 0000000..6e7ec2d --- /dev/null +++ b/miniflare-lib/src/jsMain/kotlin/com/cloudflare/miniflare/MiniflareExt.kt @@ -0,0 +1,17 @@ +package com.cloudflare.miniflare + +import org.w3c.fetch.RequestInit +import org.w3c.fetch.Response +import kotlin.js.Promise + +// This is ignored internally by Miniflare, but we need some sort of URL to call dispatchFetch +val Miniflare.DEFAULT_URL + get() = "http://localhost:8787" + +fun Miniflare.dispatchFetchForPath( + path: String, + init: RequestInit? = null +): Promise { + require(path.startsWith("/")) { "Path must start with a slash" } + return dispatchFetch("$DEFAULT_URL$path", init) +} diff --git a/miniflare-lib/src/jsMain/kotlin/com/cloudflare/miniflare/MiniflareOptionsFactory.kt b/miniflare-lib/src/jsMain/kotlin/com/cloudflare/miniflare/MiniflareOptionsFactory.kt new file mode 100644 index 0000000..0444d8c --- /dev/null +++ b/miniflare-lib/src/jsMain/kotlin/com/cloudflare/miniflare/MiniflareOptionsFactory.kt @@ -0,0 +1,44 @@ +package com.cloudflare.miniflare + +// This works better than trying to define external classes/interfaces because it avoids type checking issues +// when passing objects between Kotlin and JS with optional properties. +// https://github.com/cloudflare/miniflare/blob/v3.20231016.0/packages/miniflare/README.md#type-miniflareoptions +object MiniflareOptionsFactory { + // https://developers.cloudflare.com/workers/platform/compatibility-dates/ + const val DEFAULT_COMPATIBILITY_DATE = "2023-12-01" + + fun new( + script: Script, + cf: Boolean = false, + modules: Boolean? = true, + compatibilityDate: String = DEFAULT_COMPATIBILITY_DATE, + d1Databases: Array? = null, + ): dynamic { + val options: dynamic = object {} + + when (script) { + is Script.Inline -> { + options.script = script.script + script.scriptPath?.let { options.scriptPath = it } + } + is Script.File -> options.scriptPath = script.path + } + + modules?.let { options.modules = it } + + options.cf = cf + options.compatibilityDate = compatibilityDate + d1Databases?.let { options.d1Databases = it } + + return options + } +} + +sealed class Script { + data class Inline( + val script: String, + val scriptPath: String? = null + ) : Script() + + data class File(val path: String) : Script() +} diff --git a/miniflare-lib/src/jsTest/kotlin/com/cloudflare/miniflare/MiniflareTest.kt b/miniflare-lib/src/jsTest/kotlin/com/cloudflare/miniflare/MiniflareTest.kt new file mode 100644 index 0000000..2fbf53f --- /dev/null +++ b/miniflare-lib/src/jsTest/kotlin/com/cloudflare/miniflare/MiniflareTest.kt @@ -0,0 +1,27 @@ +package com.cloudflare.miniflare + +import kotlinx.coroutines.await +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +private const val SCRIPT = """ +addEventListener("fetch", (event) => { + event.respondWith(new Response("Hello Miniflare!")); +}) +""" + +class MiniflareTest { + @Test + fun make_request() = + runTest { + val miniflare = Miniflare(MiniflareOptionsFactory.new(Script.Inline(SCRIPT), modules = false)) + try { + val response = miniflare.dispatchFetchForPath("/").await() + assertEquals(200, response.status) + assertEquals("Hello Miniflare!", response.text().await()) + } finally { + miniflare.dispose().await() + } + } +} diff --git a/package.json b/package.json index 82ba47e..186c0d0 100644 --- a/package.json +++ b/package.json @@ -7,5 +7,8 @@ "test": "echo \"Error: no test specified\" && exit 1" }, "author": "{{ authors }}", - "license": "MIT" + "license": "MIT", + "dependencies": { + "wrangler": "^3.25.0" + } } diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..1e8a49c --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,15 @@ +enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") + +@Suppress("UnstableApiUsage") +dependencyResolutionManagement { + @Suppress("UnstableApiUsage") + repositories { + mavenCentral() + } +} + +rootProject.name = "cloudflare-kotlin-hello-world" + +include("miniflare-lib") +include("worker-integration-test") +include("worker-app") diff --git a/src/main/kotlin/main.kt b/src/main/kotlin/main.kt deleted file mode 100644 index dd8d0a6..0000000 --- a/src/main/kotlin/main.kt +++ /dev/null @@ -1,14 +0,0 @@ -import org.w3c.fetch.Request -import org.w3c.fetch.Response -import org.w3c.fetch.ResponseInit - -@OptIn(ExperimentalJsExport::class) -@JsExport -fun fetch(request: Request) : Response { - val headers: dynamic = object {} - headers["content-type"] = "text/plain" - return Response( - "Kotlin Worker hello world", - ResponseInit(headers = headers) - ) -} diff --git a/src/main/resources/index.html b/src/main/resources/index.html deleted file mode 100644 index f6c24ff..0000000 --- a/src/main/resources/index.html +++ /dev/null @@ -1,11 +0,0 @@ - - - - - kotlin-worker-hello-world - - - - - - diff --git a/worker-app/build.gradle.kts b/worker-app/build.gradle.kts new file mode 100644 index 0000000..f3bdaa6 --- /dev/null +++ b/worker-app/build.gradle.kts @@ -0,0 +1,66 @@ +plugins { + alias(libs.plugins.kotlin.multiplatform) +} + +val kotlinJsOutputFilename = "worker-app.js" + +kotlin { + js(IR) { + browser { + webpackTask { + mainOutputFileName = kotlinJsOutputFilename + } + } + binaries.executable() + } + + sourceSets { + getByName("jsMain") { + dependencies { + } + } + getByName("jsTest") { + dependencies { + implementation(kotlin("test")) + } + } + } +} + +tasks { + named("jsBrowserProductionWebpack") { + val entrypointFile = "${layout.buildDirectory.asFile.get()}/dist/js/productionExecutable/entrypoint.js" + outputs.file(entrypointFile) + + val jsEntrypoint = """ + // Import the compiled Kotlin/JS module + import app from './$kotlinJsOutputFilename'; + + // The entrypoint expected by Cloudflare + export default { + async fetch(request, env, ctx) { + return app.main(request, env, ctx); + }, + }; + """.trimIndent() + + doLast { + File(entrypointFile).writeText(jsEntrypoint) + } + } +} + +// This provides a way for worker-integration-test to wait for worker-app to be built +configurations.register("workerAppProduction") { + isCanBeConsumed = true + isCanBeResolved = false +}.also { configuration -> + artifacts { + val webpackTask = tasks.getByName("jsBrowserProductionWebpack") + webpackTask.outputs.files.forEach { file -> + add(configuration.name, file) { + builtBy(webpackTask) + } + } + } +} diff --git a/worker-app/src/jsMain/kotlin/Application.kt b/worker-app/src/jsMain/kotlin/Application.kt new file mode 100644 index 0000000..d6891bb --- /dev/null +++ b/worker-app/src/jsMain/kotlin/Application.kt @@ -0,0 +1,17 @@ +import org.w3c.fetch.Request +import org.w3c.fetch.Response +import org.w3c.fetch.ResponseInit +import kotlin.js.Promise + +// Implement your Kotlin application logic here +internal fun application( + @Suppress("UNUSED_PARAMETER") request: Request +) = Promise { resolve, reject -> + runCatching { + val headers: dynamic = object {} + headers["content-type"] = "text/plain" + resolve(Response("Hello world", ResponseInit(headers = headers))) + }.onFailure { + reject(RuntimeException("Failed to respond")) + } +} diff --git a/worker-app/src/jsMain/kotlin/Entrypoint.kt b/worker-app/src/jsMain/kotlin/Entrypoint.kt new file mode 100644 index 0000000..b04c423 --- /dev/null +++ b/worker-app/src/jsMain/kotlin/Entrypoint.kt @@ -0,0 +1,15 @@ +import org.w3c.fetch.Request +import org.w3c.fetch.Response +import kotlin.js.Promise + +// This is the Kotlin entrypoint, which the Javascript entrypoint (see build.gradle.kts) will call. +// The unused parameters are Cloudflare-specific and can be used to access additional Cloudflare features +@OptIn(ExperimentalJsExport::class) +@JsExport +fun main( + request: Request, + @Suppress("UNUSED_PARAMETER") + cloudflareEnv: dynamic, + @Suppress("UNUSED_PARAMETER") + cloudflareCtx: dynamic +): Promise = application(request) diff --git a/worker-integration-test/build.gradle.kts b/worker-integration-test/build.gradle.kts new file mode 100644 index 0000000..ed5b44e --- /dev/null +++ b/worker-integration-test/build.gradle.kts @@ -0,0 +1,56 @@ +plugins { + alias(libs.plugins.kotlin.multiplatform) +} + +val generateBuildConfigTask = tasks.register("buildConfig") { + // An improvement would be to get the file name from the task generating this file + val inputFile = "worker-app/build/dist/js/productionExecutable/entrypoint.js" + val generatedDir = "${layout.buildDirectory.asFile.get()}/generated" + + outputs.dir(File(generatedDir)) + + doLast { + val outputFile = File("$generatedDir/BuildConfig.kt") + outputFile.parentFile.mkdirs() + + buildString { + appendLine("// Generated file") + appendLine() + appendLine("object BuildConfig {") + appendLine(" const val ENTRYPOINT_PATH: String = \"${inputFile}\"") + appendLine("}") + }.let { buildConfig -> + outputFile.writeText(buildConfig) + } + } +} + +kotlin { + js(IR) { + nodejs() + } + + sourceSets { + getByName("jsMain") { + dependencies { + } + } + getByName("jsTest") { + kotlin.srcDir(generateBuildConfigTask) + dependencies { + implementation(kotlin("test")) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.coroutines.test) + implementation(projects.miniflareLib) + implementation( + project( + mapOf( + "path" to ":worker-app", + "configuration" to "workerAppProduction" + ) + ) + ) + } + } + } +} diff --git a/worker-integration-test/src/jsTest/kotlin/IntegrationTest.kt b/worker-integration-test/src/jsTest/kotlin/IntegrationTest.kt new file mode 100644 index 0000000..02b17bb --- /dev/null +++ b/worker-integration-test/src/jsTest/kotlin/IntegrationTest.kt @@ -0,0 +1,37 @@ +import kotlinx.coroutines.await +import kotlinx.coroutines.test.runTest +import com.cloudflare.miniflare.Script +import com.cloudflare.miniflare.Miniflare +import com.cloudflare.miniflare.MiniflareOptionsFactory +import com.cloudflare.miniflare.dispatchFetchForPath +import BuildConfig +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class IntegrationTest { + @BeforeTest + fun fixEnv() { + // There's an inconsistency in the working directory; this ensures it is the root Gradle project + js("process.chdir(process.env[\"PWD\"])") + } + + @Test + fun hello_world() = + runTest { + val miniflare = + Miniflare( + MiniflareOptionsFactory.new( + Script.File(BuildConfig.ENTRYPOINT_PATH), + modules = true + ) + ) + try { + val response = miniflare.dispatchFetchForPath("/").await() + assertEquals(200, response.status) + assertEquals("Hello world", response.text().await()) + } finally { + miniflare.dispose().await() + } + } +} diff --git a/wrangler.toml b/wrangler.toml index 64f5f08..7f5dd5d 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -1,4 +1,2 @@ -name = "" -account_id = "" -workers_dev = true -compatibility_date = "2022-08-11" \ No newline at end of file +main = "worker-app/build/dist/js/productionExecutable/entrypoint.js" +compatibility_date = "2023-12-01"