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)