diff --git a/.gitattributes b/.gitattributes index 49d2fd388..c44e5f688 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,5 @@ +/*.js -linguist-detectable +/*.mjs -linguist-detectable *.proto linguist-language=Protocol-Buffer linguist-detectable *.soy linguist-language=Closure-Templates linguist-detectable *.ts linguist-language=TypeScript linguist-detectable diff --git a/packages/graalvm/api/graalvm.api b/packages/graalvm/api/graalvm.api index c4f0585ce..6cac3144e 100644 --- a/packages/graalvm/api/graalvm.api +++ b/packages/graalvm/api/graalvm.api @@ -4196,6 +4196,17 @@ public final class elide/runtime/intrinsics/js/typed/UUID$UUIDType : java/lang/E public static fun values ()[Lelide/runtime/intrinsics/js/typed/UUID$UUIDType; } +public abstract interface class elide/runtime/intrinsics/server/http/ExpressResponseAPI { + public abstract fun append (Ljava/lang/String;Ljava/lang/String;)V + public static synthetic fun append$default (Lelide/runtime/intrinsics/server/http/ExpressResponseAPI;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)V + public abstract fun end ()V + public abstract fun get (Ljava/lang/String;)Ljava/lang/String; + public abstract fun send (ILorg/graalvm/polyglot/Value;)V + public abstract fun set (Ljava/lang/String;Ljava/lang/String;)V + public static synthetic fun set$default (Lelide/runtime/intrinsics/server/http/ExpressResponseAPI;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)V + public abstract fun status (I)V +} + public final class elide/runtime/intrinsics/server/http/HttpContext : java/util/Map, kotlin/jvm/internal/markers/KMutableMap, org/graalvm/polyglot/proxy/ProxyObject { public fun clear ()V public final fun containsKey (Ljava/lang/Object;)Z @@ -4243,7 +4254,9 @@ public abstract interface class elide/runtime/intrinsics/server/http/HttpRequest public abstract fun getUri ()Ljava/lang/String; } -public abstract interface class elide/runtime/intrinsics/server/http/HttpResponse { +public abstract interface class elide/runtime/intrinsics/server/http/HttpResponse : elide/runtime/intrinsics/server/http/ExpressResponseAPI { + public fun append (Ljava/lang/String;Ljava/lang/String;)V + public abstract fun header (Ljava/lang/String;Ljava/lang/String;)V public abstract fun send (ILorg/graalvm/polyglot/Value;)V } diff --git a/packages/graalvm/detekt-baseline.xml b/packages/graalvm/detekt-baseline.xml index a5fdcc58b..0ec61a4fa 100644 --- a/packages/graalvm/detekt-baseline.xml +++ b/packages/graalvm/detekt-baseline.xml @@ -6,6 +6,7 @@ ComplexCondition:NodeAssertTest.kt$NodeAssertTest$(left is Value && left.isNull) || (right is Value && right.isNull) CyclomaticComplexMethod:ConsoleIntrinsic.kt$ConsoleIntrinsic$internal fun formatLogComponent(arg: Any?): Any CyclomaticComplexMethod:CustomEvent.kt$CustomEvent$override fun getMember(key: String?): Any? + CyclomaticComplexMethod:NettyHttpResponse.kt$NettyHttpResponse$override fun getMember(key: String?): Any? CyclomaticComplexMethod:NodeEvents.kt$NodeEventsModuleFacade$@Polyglot override fun getMember(key: String): Any? CyclomaticComplexMethod:NodeOperatingSystem.kt$NodeOperatingSystem.ModuleBase$override fun getMember(key: String?): Any? CyclomaticComplexMethod:NodePaths.kt$NodePaths.BasePaths$@Polyglot override fun format(pathObject: Any): String diff --git a/packages/graalvm/src/main/kotlin/elide/runtime/intrinsics/server/http/ExpressResponseAPI.kt b/packages/graalvm/src/main/kotlin/elide/runtime/intrinsics/server/http/ExpressResponseAPI.kt new file mode 100644 index 000000000..9d4c857ed --- /dev/null +++ b/packages/graalvm/src/main/kotlin/elide/runtime/intrinsics/server/http/ExpressResponseAPI.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2024 Elide Technologies, Inc. + * + * Licensed under the MIT license (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://opensource.org/license/mit/ + * + * 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. + */ +package elide.runtime.intrinsics.server.http + +import elide.annotations.API +import elide.runtime.core.DelicateElideApi +import elide.runtime.core.PolyglotValue +import elide.vm.annotations.Polyglot + +/** + * # Express Response API + * + * Defines methods available on an Express `Response` object; these are mixed into the standard [HttpResponse] so that + * guests may specify responses using this API. + */ +@API @DelicateElideApi public interface ExpressResponseAPI { + /** + * Exported method allowing guest code to set the response [status] code. + * + * This method is not terminal, unlike [send]. + * + * @param status The HTTP status code to send. + */ + @Polyglot public fun status(status: Int) + + /** + * Exported method allowing guest code to end the request/response cycle; equivalent to calling [send]. + * + * This method is terminal. + */ + @Polyglot public fun end() + + /** + * Exported method allowing guest code to send a response to the client with the given [status] code and [body]. + * + * @param status The HTTP status code to send. + * @param body The body of the response to send. + */ + @Polyglot public fun send(status: Int, body: PolyglotValue?) + + /** + * Get a header from the response; this method is exported to guest code. + * + * @param name The name of the header to set. + */ + @Polyglot public fun get(name: String): String? + + /** + * Set a header to the response; this method is exported to guest code. + * + * Note that headers must be provided before [send] is called. + * This method will set a header to the response, overwriting any existing header(s) by the same name. + * + * @param name The name of the header to set. + * @param value The value of the header to set. + */ + @Polyglot public fun set(name: String, value: String? = null) + + /** + * Append a header to the response; this method is exported to guest code. + * + * Note that headers must be provided before [send] is called. + * This method will add a header to the response even if there is already a header by the same name. + * + * @param name The name of the header to set. + * @param value The value of the header to set. + */ + @Polyglot public fun append(name: String, value: String? = null) +} diff --git a/packages/graalvm/src/main/kotlin/elide/runtime/intrinsics/server/http/HttpResponse.kt b/packages/graalvm/src/main/kotlin/elide/runtime/intrinsics/server/http/HttpResponse.kt index e679afd96..6e599c12d 100644 --- a/packages/graalvm/src/main/kotlin/elide/runtime/intrinsics/server/http/HttpResponse.kt +++ b/packages/graalvm/src/main/kotlin/elide/runtime/intrinsics/server/http/HttpResponse.kt @@ -10,17 +10,43 @@ * 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. */ - package elide.runtime.intrinsics.server.http -import org.graalvm.polyglot.HostAccess.Export +import elide.annotations.API import elide.runtime.core.DelicateElideApi import elide.runtime.core.PolyglotValue +import elide.vm.annotations.Polyglot /** Represents an HTTP response returned by the server, accessible from guest code. */ -@DelicateElideApi public interface HttpResponse { +@API @DelicateElideApi public interface HttpResponse: ExpressResponseAPI { + /** + * Provide a header to the response; this method is exported to guest code. + * + * Note that headers must be provided before [send] is called. + * + * @param name The name of the header to set. + * @param value The value of the header to set. + */ + @Polyglot public fun header(name: String, value: String) + /** * Exported method allowing guest code to send a response to the client with the given [status] code and [body]. + * + * @param status The HTTP status code to send. + * @param body The body of the response to send. + */ + @Polyglot override fun send(status: Int, body: PolyglotValue?) + + /** + * Append a header to the response; this method is exported to guest code. + * + * Note that headers must be provided before [send] is called. + * This method will add a header to the response even if there is already a header by the same name. + * + * @param name The name of the header to set. + * @param value The value of the header to set. */ - @Export public fun send(status: Int, body: PolyglotValue?) + @Polyglot override fun append(name: String, value: String?) { + header(name, value ?: "") + } } diff --git a/packages/graalvm/src/main/kotlin/elide/runtime/intrinsics/server/http/netty/NettyHttpResponse.kt b/packages/graalvm/src/main/kotlin/elide/runtime/intrinsics/server/http/netty/NettyHttpResponse.kt index ba6318f27..3cac7033d 100644 --- a/packages/graalvm/src/main/kotlin/elide/runtime/intrinsics/server/http/netty/NettyHttpResponse.kt +++ b/packages/graalvm/src/main/kotlin/elide/runtime/intrinsics/server/http/netty/NettyHttpResponse.kt @@ -15,23 +15,96 @@ package elide.runtime.intrinsics.server.http.netty import io.netty.buffer.Unpooled import io.netty.channel.ChannelHandlerContext import io.netty.handler.codec.http.* -import org.graalvm.polyglot.HostAccess.Export +import org.graalvm.polyglot.Value +import org.graalvm.polyglot.proxy.ProxyExecutable +import org.graalvm.polyglot.proxy.ProxyObject +import java.text.DateFormat import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference import elide.runtime.core.DelicateElideApi import elide.runtime.core.PolyglotValue +import elide.runtime.gvm.internals.intrinsics.js.JsError import elide.runtime.intrinsics.server.http.HttpRequest import elide.runtime.intrinsics.server.http.HttpResponse +import elide.vm.annotations.Polyglot + +// Methods and properties exposed to guest code on responses. +private val NETTY_HTTP_RESPONSE_PROPS_AND_METHODS = arrayOf( + "append", + "end", + "get", + "header", + "send", + "set", + "status", +) /** [HttpRequest] implementation wrapping a Netty handler context. */ -@DelicateElideApi internal class NettyHttpResponse(private val context: ChannelHandlerContext) : HttpResponse { +@DelicateElideApi internal class NettyHttpResponse( + private val context: ChannelHandlerContext, + private val includeDefaults: Boolean = true, +) + : HttpResponse, ProxyObject { /** Whether the response has already been sent. */ private val sent = AtomicBoolean(false) + /** Whether the response has already been sent. */ + private val body = AtomicReference(null) + + /** Explicit status set by guest code. */ + private val status = AtomicInteger(0) + + /** The HTTP version of the response. */ + @Volatile private var httpVersion = HttpVersion.HTTP_1_1 + /** Headers for this response, dispatched once the response is sent to the client. */ private val headers = DefaultHttpHeaders() - @Export override fun send(status: Int, body: PolyglotValue?) { - send(HttpResponseStatus.valueOf(status), body) + init { + // prepare headers + headers + .set(HttpHeaderNames.CONTENT_TYPE, HttpHeaderValues.TEXT_PLAIN) + .set(HttpHeaderNames.SERVER, "elide") + } + + @Polyglot override fun header(name: String, value: String) { + headers.set(name, value) + } + + @Polyglot override fun set(name: String, value: String?) { + headers.set(name, value) + } + + @Polyglot override fun get(name: String): String? { + return headers.get(name) + } + + @Polyglot override fun append(name: String, value: String?) { + headers.add(name, value) + } + + @Polyglot override fun status(status: Int) { + this.status.set(status) + } + + @Polyglot override fun send(status: Int, body: PolyglotValue?) { + status(status) + this.body.set(body) + end() + } + + @Polyglot override fun end() { + val status = status.get() + val effective = if (status == 0) HttpResponseStatus.OK.code() else status + send(HttpResponseStatus.valueOf(effective), body.get()) + } + + // Fills response headers expected by-spec. + private fun fillResponseHeaders() { + if (includeDefaults) { + headers.set(HttpHeaderNames.DATE, DateFormat.getInstance().format(System.currentTimeMillis())) + } } /** @@ -47,17 +120,14 @@ import elide.runtime.intrinsics.server.http.HttpResponse // treat any type of value as a string (force conversion) val content = body?.let { Unpooled.wrappedBuffer(it.toString().toByteArray()) } ?: Unpooled.EMPTY_BUFFER - // prepare headers - headers - .set(HttpHeaderNames.CONTENT_TYPE, HttpHeaderValues.TEXT_PLAIN) - .set(HttpHeaderNames.SERVER, "Elide") - // prepare the response object + fillResponseHeaders() + val response = if (content != null) { headers.set(HttpHeaderNames.CONTENT_LENGTH, content.writerIndex()) DefaultFullHttpResponse( - /* version = */ HttpVersion.HTTP_1_1, + /* version = */ httpVersion, /* status = */ status, /* content = */ content, /* headers = */ headers, @@ -65,7 +135,7 @@ import elide.runtime.intrinsics.server.http.HttpResponse ) } else { DefaultHttpResponse( - /* version = */ HttpVersion.HTTP_1_1, + /* version = */ httpVersion, /* status = */ status, /* headers = */ headers, ) @@ -74,4 +144,75 @@ import elide.runtime.intrinsics.server.http.HttpResponse // send the response context.write(response) } + + override fun getMemberKeys(): Array = NETTY_HTTP_RESPONSE_PROPS_AND_METHODS + override fun hasMember(key: String?): Boolean = key != null && key in NETTY_HTTP_RESPONSE_PROPS_AND_METHODS + override fun putMember(key: String?, value: Value?) { + // no-op + } + + override fun removeMember(key: String?): Boolean { + return false // not supported + } + + override fun getMember(key: String?): Any? = when (key) { + "get" -> ProxyExecutable { + val name = it.getOrNull(0) + when { + name == null || !name.isString -> throw JsError.valueError("Header name must be a string") + else -> get(name.asString()) + } + } + + "set" -> ProxyExecutable { + val name = it.getOrNull(0) + val value = it.getOrNull(1) + when { + name == null || !name.isString -> throw JsError.valueError("Header name must be a string") + value == null -> throw JsError.typeError("Header value is required") + else -> set(name.asString(), value.asString()) + } + } + + "append" -> ProxyExecutable { + val name = it.getOrNull(0) + val value = it.getOrNull(1) + when { + name == null || !name.isString -> throw JsError.valueError("Header name must be a string") + value == null -> throw JsError.typeError("Header value is required") + else -> append(name.asString(), value.asString()) + } + } + + "status" -> ProxyExecutable { + val status = it.getOrNull(0) + when { + status == null || !status.fitsInInt() -> throw JsError.typeError("Status code must be an integer") + else -> status(status.asInt()) + } + } + + "header" -> ProxyExecutable { + val name = it.getOrNull(0) + val value = it.getOrNull(1) + when { + name == null || !name.isString -> throw JsError.valueError("Header name must be a string") + value == null -> throw JsError.typeError("Header value is required") + else -> header(name.asString(), value.asString()) + } + } + + "send" -> ProxyExecutable { + val status = it.getOrNull(0) + val body = it.getOrNull(1) + when { + status == null || !status.fitsInInt() -> throw JsError.typeError("Status code must be an integer") + else -> send(status.asInt(), body) + } + } + + "end" -> ProxyExecutable { this.end() } + + else -> null + } } diff --git a/packages/graalvm/src/main/kotlin/elide/runtime/intrinsics/server/http/netty/NettyRequestHandler.kt b/packages/graalvm/src/main/kotlin/elide/runtime/intrinsics/server/http/netty/NettyRequestHandler.kt index 0418e1907..8dc256fbc 100644 --- a/packages/graalvm/src/main/kotlin/elide/runtime/intrinsics/server/http/netty/NettyRequestHandler.kt +++ b/packages/graalvm/src/main/kotlin/elide/runtime/intrinsics/server/http/netty/NettyRequestHandler.kt @@ -47,7 +47,7 @@ import io.netty.handler.codec.http.HttpRequest as NettyHttpRequest // prepare the wrappers val request = NettyHttpRequest(message) - val response = NettyHttpResponse(channelContext) + val response = NettyHttpResponse(channelContext, includeDefaults = true) val context = HttpContext() // resolve the handler pipeline (or default to 'not found' if empty)