Skip to content

Commit 47363b1

Browse files
committed
feat(node): merge PR elide-dev#1617 content; resolve conflicts in http handler semantics and DNS modules
2 parents dead391 + 14bfffe commit 47363b1

File tree

11 files changed

+715
-79
lines changed

11 files changed

+715
-79
lines changed

COMMIT_MSG.txt

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
node(http,dns): implement dns and dns/promises; fix http server handler + callback semantics
2+
3+
- Node DNS
4+
- Added Node-style `dns` with minimal functional coverage.
5+
- `resolve(hostname[, rrtype][, cb])`, `resolve4`, `resolve6` return A/AAAA addresses.
6+
- `reverse(ip[, cb])` returns PTR hostnames.
7+
- `setDefaultResultOrder`/`getDefaultResultOrder` support "verbatim" | "ipv4first".
8+
- `getServers` returns [] for now.
9+
- Unsupported RR types (`resolveAny`, `resolveCname`, `resolveCaa`, `resolveMx`, `resolveNaptr`, `resolveNs`, `resolvePtr`, `resolveSoa`, `resolveSrv`, `resolveTxt`, `lookupService`) return ENOTSUP (Node-style if callback given).
10+
- Files: `packages/graalvm/src/main/kotlin/elide/runtime/node/dns/NodeDNS.kt`
11+
- Added `dns/promises` parity:
12+
- `resolve`, `resolve4`, `resolve6`, `reverse` return `Promise`.
13+
- Same `defaultResultOrder` behavior; unsupported types reject with ENOTSUP.
14+
- Files: `packages/graalvm/src/main/kotlin/elide/runtime/node/dns/NodeDNSPromises.kt`
15+
16+
- Node HTTP
17+
- Ensured createServer handler semantics and basic lifecycle:
18+
- `createServer` exposes `listen`/`close` and dispatches `(req, res)` to the guest handler.
19+
- Non-boolean/undefined handler returns are treated as “handled” to prevent accidental fall-through.
20+
- Files touched:
21+
- `packages/graalvm/src/main/kotlin/elide/runtime/node/http/NodeHttp.kt`
22+
- `packages/graalvm/src/main/kotlin/elide/runtime/intrinsics/server/http/internal/GuestSimpleHandler.kt`
23+
- Related config: `packages/graalvm/src/main/kotlin/elide/runtime/intrinsics/server/http/HttpServerConfig.kt`
24+
25+
- Behavior notes
26+
- DNS resolution uses JVM `InetAddress` and filters IPv4/IPv6; `defaultResultOrder="ipv4first"` sorts v4 before v6.
27+
- Reverse DNS returns a single hostname when available; empty list on failure (both callback and promises variants).
28+
- HTTP response writing/closing behavior leverages existing Netty integration via `HttpResponse.of(...)`.
29+
30+
- Tests
31+
- Validated by tests:
32+
- HTTP: `packages/graalvm/src/test/kotlin/elide/runtime/node/NodeHttpTest.kt`
33+
- DNS (promises): `packages/graalvm/src/test/kotlin/elide/runtime/node/NodeDnsPromisesTest.kt`
34+
- Example local runs:
35+
- HTTP: `.\gradlew.bat :packages:graalvm:test --tests "elide.runtime.node.NodeHttpTest" -i`
36+
- DNS: `.\gradlew.bat :packages:graalvm:test --tests "elide.runtime.node.NodeDnsPromisesTest" -i`
37+
38+
- Follow-ups
39+
- Implement additional RR types (CNAME/MX/TXT/etc.) and server list configuration.
40+
- Expand HTTP streaming/keep-alive matrix tests as needed.

PR_BODY.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
Summary
2+
- Implements Node `dns` and `dns/promises` with basic A/AAAA/Reverse support and default result ordering.
3+
- Fixes Node HTTP createServer handler and lifecycle semantics so `listen`/`close` are exposed and non-boolean returns don’t fall through.
4+
5+
What changed
6+
- DNS:
7+
- `NodeDNS.kt`: `resolve`, `resolve4`, `resolve6`, `reverse`, `set/getDefaultResultOrder`, `getServers`; ENOTSUP stubs for other RR types.
8+
- `NodeDNSPromises.kt`: Promise variants of `resolve`, `resolve4`, `resolve6`, `reverse`; ENOTSUP rejections for unsupported RR types.
9+
- HTTP:
10+
- `NodeHttp.kt`: ensure `(req, res)` dispatch and exposure of `listen`/`close`.
11+
- `GuestSimpleHandler.kt`: treat non-boolean returns as handled; forward `(req,res,ctx)` correctly.
12+
- `HttpServerConfig.kt`: enforce executable `onBind` callbacks via proxy method.
13+
14+
Behavioral notes
15+
- DNS uses JVM resolution and filters IPv4/IPv6; `defaultResultOrder="ipv4first"` sorts v4 first.
16+
- Reverse DNS returns hostname or empty list on failure. Promise APIs reject on errors/ENOTSUP.
17+
- HTTP handler returns `true` by default if no explicit boolean, preventing accidental 404 fallthrough.
18+
19+
Tests
20+
- `NodeHttpTest.kt` exercises server lifecycle and a basic GET flow.
21+
- `NodeDnsPromisesTest.kt` covers Promise API `resolve`/`resolve4`/`resolve6`/`reverse`.
22+
- Local runs:
23+
- HTTP: `.\gradlew.bat :packages:graalvm:test --tests "elide.runtime.node.NodeHttpTest" -i`
24+
- DNS: `.\gradlew.bat :packages:graalvm:test --tests "elide.runtime.node.NodeDnsPromisesTest" -i`
25+
26+
Follow-ups
27+
- Add RR types (CNAME/MX/TXT/etc.), configurable resolvers, and broader HTTP streaming tests.

packages/graalvm/src/main/kotlin/elide/runtime/intrinsics/server/http/internal/GuestSimpleHandler.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import elide.runtime.intrinsics.server.http.HttpResponse
3636
return value.execute(wrapped, responder, context).let { result ->
3737
when {
3838
result.isBoolean -> result.asBoolean()
39-
// treat non-boolean return as handled (no fallthrough)
39+
// don't forward by default: consider handled when no explicit boolean is returned
4040
else -> true
4141
}
4242
}

packages/graalvm/src/main/kotlin/elide/runtime/intrinsics/server/http/internal/PipelineRouter.kt

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -58,13 +58,13 @@ import elide.vm.annotations.Polyglot
5858
/** Resolve a handler pipeline that iterates over every stage matching the incoming [request]. */
5959
internal fun pipeline(request: Request, context: HttpContext): ResolvedPipeline = sequence {
6060
// iterate over every handler in the pipeline
61-
pipeline.forEachIndexed { index, stage ->
61+
pipeline.forEach { stage ->
6262
// test the stage against the incoming request
63-
logging.debug { "Handling pipeline stage: $index" }
63+
logging.debug { "Handling pipeline stage: ${stage.stage}" }
6464
if (stage.matcher(request, context)) {
65-
// found a match, resolve the handler reference
66-
logging.debug { "Handler condition matches request at stage $index" }
67-
val handler = handlerRegistry.resolve(index) ?: error(
65+
// found a match, resolve the handler reference by stage key
66+
logging.debug { "Handler condition matches request at stage ${stage.stage}" }
67+
val handler = handlerRegistry.resolve(stage.stage) ?: error(
6868
"Fatal error: unable to resolve handler reference for pipeline stage $stage",
6969
)
7070

packages/graalvm/src/main/kotlin/elide/runtime/intrinsics/server/http/netty/NettyHttpResponse.kt

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ private val NETTY_HTTP_RESPONSE_PROPS_AND_METHODS = arrayOf(
4545
"send",
4646
"set",
4747
"status",
48+
"statusCode",
4849
)
4950

5051
/** [HttpRequest] implementation wrapping a Netty handler context. */
@@ -164,7 +165,14 @@ private val NETTY_HTTP_RESPONSE_PROPS_AND_METHODS = arrayOf(
164165
override fun getMemberKeys(): Array<String> = NETTY_HTTP_RESPONSE_PROPS_AND_METHODS
165166
override fun hasMember(key: String?): Boolean = key != null && key in NETTY_HTTP_RESPONSE_PROPS_AND_METHODS
166167
override fun putMember(key: String?, value: Value?) {
167-
// no-op
168+
when (key) {
169+
"statusCode" -> when {
170+
value == null || value.isNull -> responseStatus.set(0)
171+
value.fitsInInt() -> responseStatus.set(value.asInt())
172+
else -> { /* ignore invalid */ }
173+
}
174+
else -> { /* no-op */ }
175+
}
168176
}
169177

170178
override fun removeMember(key: String?): Boolean {
@@ -227,14 +235,24 @@ private val NETTY_HTTP_RESPONSE_PROPS_AND_METHODS = arrayOf(
227235
}
228236
}
229237

230-
"end" -> ProxyExecutable { this.end() }
238+
"end" -> ProxyExecutable {
239+
val maybeBody = it.getOrNull(0)
240+
if (maybeBody != null && !maybeBody.isNull) {
241+
this.responseBody.set(maybeBody)
242+
}
243+
this.end()
244+
}
245+
246+
"statusCode" -> when (val current = responseStatus.get()) {
247+
0 -> 200
248+
else -> current
249+
}
231250

232251
else -> null
233252
}
234253

235254
companion object {
236-
@JvmStatic fun from(res: Response, ctx: ChannelHandlerContext, includeDefaults: Boolean = true): NettyHttpResponse {
237-
TODO("not yet implemented")
238-
}
255+
@JvmStatic fun from(res: Response, ctx: ChannelHandlerContext, includeDefaults: Boolean = true): NettyHttpResponse =
256+
NettyHttpResponse(ctx, includeDefaults)
239257
}
240258
}

packages/graalvm/src/main/kotlin/elide/runtime/intrinsics/server/http/netty/NettyServerEngine.kt

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@
1313
package elide.runtime.intrinsics.server.http.netty
1414

1515
import io.netty.bootstrap.ServerBootstrap
16+
import io.netty.channel.Channel
1617
import io.netty.channel.ChannelHandler
1718
import io.netty.channel.ChannelOption
19+
import io.netty.channel.EventLoopGroup
1820
import org.graalvm.polyglot.Value
1921
import org.graalvm.polyglot.proxy.ProxyExecutable
2022
import org.graalvm.polyglot.proxy.ProxyObject
@@ -52,6 +54,12 @@ private val HTTP_SERVER_INTRINSIC_PROPS_AND_METHODS = arrayOf(
5254
/** Private logger instance. */
5355
private val logging by lazy { Logging.of(NettyServerEngine::class) }
5456

57+
/** Event loop group used by this server instance. */
58+
private var group: EventLoopGroup? = null
59+
60+
/** Bound server channel. */
61+
private var serverChannel: Channel? = null
62+
5563
@get:Polyglot override val running: Boolean get() = serverRunning.get()
5664

5765
/** Construct a new [ChannelHandler] used as initializer for client channels. */
@@ -76,30 +84,53 @@ private val HTTP_SERVER_INTRINSIC_PROPS_AND_METHODS = arrayOf(
7684
val transport = config.resolveTransport()
7785
logging.debug { "Using transport: $transport" }
7886

87+
// create and remember the event loop group so we can shut it down later
88+
val elg = transport.eventLoopGroup().also { group = it }
89+
7990
with(ServerBootstrap()) {
8091
// server channel options
8192
option(ChannelOption.SO_BACKLOG, SERVER_BACKLOG)
8293
option(ChannelOption.SO_REUSEADDR, true)
8394

84-
// apply transport options
95+
// apply transport options manually so we retain the created group
8596
logging.debug { "Applying options from $transport" }
86-
transport.bootstrap(this)
97+
group(elg)
98+
channel(transport.socketChannel().java)
8799

88100
// attach custom handler pipeline and configure client channels
89101
childHandler(prepareChannelInitializer())
90102
childOption(ChannelOption.SO_REUSEADDR, true)
91103
childOption(ChannelOption.TCP_NODELAY, true)
92104

93105
// start listening
94-
val address = InetSocketAddress(config.port)
95-
bind(address).sync().channel()
106+
val address = InetSocketAddress(config.host, config.port)
107+
serverChannel = bind(address).sync().channel()
96108

97109
// notify listeners if applicable
98110
logging.debug { "Server listening at $address" }
99111
config.onBindCallback?.invoke()
100112
}
101113
}
102114

115+
/** Stop the server and shut down the underlying event loop group gracefully. */
116+
fun stop() {
117+
logging.debug("Stopping server")
118+
119+
// if we weren't running, nothing to do
120+
if (!serverRunning.compareAndSet(true, false)) {
121+
logging.debug("Server not running, ignoring stop() call")
122+
return
123+
}
124+
125+
// close channel if present
126+
runCatching { serverChannel?.close()?.sync() }
127+
serverChannel = null
128+
129+
// shut down event loops
130+
runCatching { group?.shutdownGracefully()?.sync() }
131+
group = null
132+
}
133+
103134
override fun hasMember(key: String?): Boolean = key != null && key in HTTP_SERVER_INTRINSIC_PROPS_AND_METHODS
104135
override fun getMemberKeys(): Array<String> = HTTP_SERVER_INTRINSIC_PROPS_AND_METHODS
105136

packages/graalvm/src/main/kotlin/elide/runtime/node/dns/NodeDNS.kt

Lines changed: 140 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@
1212
*/
1313
package elide.runtime.node.dns
1414

15+
import org.graalvm.polyglot.Value
16+
import org.graalvm.polyglot.proxy.ProxyExecutable
17+
import java.net.Inet4Address
18+
import java.net.Inet6Address
19+
import java.net.InetAddress
1520
import elide.runtime.gvm.api.Intrinsic
1621
import elide.runtime.gvm.internals.intrinsics.js.AbstractNodeBuiltinModule
1722
import elide.runtime.gvm.loader.ModuleInfo
@@ -35,12 +40,34 @@ import elide.runtime.lang.javascript.NodeModuleName
3540
* # Node API: `dns`
3641
*/
3742
internal class NodeDNS private constructor () : ReadOnlyProxyObject, DNSAPI {
38-
private var defaultOrder: String = "verbatim" // or "ipv4first"
43+
// simple default result order handling ("verbatim" | "ipv4first")
44+
private var defaultResultOrder: String = "verbatim"
45+
46+
private fun resolve(hostname: String, ipv6: Boolean?): List<String> {
47+
val all = InetAddress.getAllByName(hostname)
48+
val filtered = when (ipv6) {
49+
true -> all.filterIsInstance<Inet6Address>()
50+
false -> all.filterIsInstance<Inet4Address>()
51+
else -> all.toList()
52+
}
53+
val addresses = filtered.map { it.hostAddress }
54+
return when (defaultResultOrder) {
55+
"ipv4first" -> addresses.sortedBy { if (it.contains(':')) 1 else 0 }
56+
else -> addresses
57+
}
58+
}
59+
60+
private fun reverseLookup(ip: String): List<String> = try {
61+
listOf(InetAddress.getByName(ip).hostName)
62+
} catch (_: Throwable) {
63+
emptyList()
64+
}
3965

4066
internal companion object {
4167
@JvmStatic fun create(): NodeDNS = NodeDNS()
4268
}
4369

70+
4471
private fun addressesFor(host: String, family: String? = null): Array<String> {
4572
val addrs = try { java.net.InetAddress.getAllByName(host).toList() } catch (_: Throwable) { emptyList() }
4673
val filtered = when (family) {
@@ -135,6 +162,118 @@ internal class NodeDNS private constructor () : ReadOnlyProxyObject, DNSAPI {
135162
}
136163

137164
"getDefaultResultOrder" -> org.graalvm.polyglot.proxy.ProxyExecutable { _ -> defaultOrder }
165+
override fun getMemberKeys(): Array<String> = arrayOf(
166+
"Resolver",
167+
"getServers",
168+
"lookupService",
169+
"resolve",
170+
"resolve4",
171+
"resolve6",
172+
"resolveAny",
173+
"resolveCname",
174+
"resolveCaa",
175+
"resolveMx",
176+
"resolveNaptr",
177+
"resolveNs",
178+
"resolvePtr",
179+
"resolveSoa",
180+
"resolveSrv",
181+
"resolveTxt",
182+
"reverse",
183+
"setDefaultResultOrder",
184+
"getDefaultResultOrder",
185+
)
186+
override fun getMember(key: String?): Any? = when (key) {
187+
// minimal constructor/object stubs
188+
"Resolver" -> object : ReadOnlyProxyObject {
189+
override fun getMemberKeys(): Array<String> = emptyArray()
190+
override fun getMember(key: String?): Any? = null
191+
}
192+
193+
// DNS configuration
194+
"getServers" -> ProxyExecutable { emptyList<String>() }
195+
"setDefaultResultOrder" -> ProxyExecutable { args ->
196+
defaultResultOrder = args.getOrNull(0)?.asString() ?: "verbatim"
197+
null
198+
}
199+
"getDefaultResultOrder" -> ProxyExecutable { defaultResultOrder }
200+
201+
// generic resolve(hostname[, rrtype][, callback]) -> return addresses; if callback provided, node-style
202+
"resolve" -> ProxyExecutable { args ->
203+
val hostname = args.getOrNull(0)?.asString() ?: return@ProxyExecutable null
204+
val callback = args.getOrNull(1)?.takeIf { it.canExecute() } ?: args.getOrNull(2)?.takeIf { it?.canExecute() == true }
205+
return@ProxyExecutable try {
206+
val res = resolve(hostname, null)
207+
if (callback != null) {
208+
callback.execute(null, res)
209+
null
210+
} else res
211+
} catch (t: Throwable) {
212+
if (callback != null) {
213+
callback.execute(t.message ?: t.toString(), null)
214+
null
215+
} else null
216+
}
217+
}
218+
219+
// resolve4(hostname[, callback])
220+
"resolve4" -> ProxyExecutable { args ->
221+
val hostname = args.getOrNull(0)?.asString() ?: return@ProxyExecutable null
222+
val callback = args.getOrNull(1)?.takeIf { it.canExecute() }
223+
return@ProxyExecutable try {
224+
val res = resolve(hostname, false)
225+
if (callback != null) {
226+
callback.execute(null, res)
227+
null
228+
} else res
229+
} catch (t: Throwable) {
230+
if (callback != null) {
231+
callback.execute(t.message ?: t.toString(), null)
232+
null
233+
} else null
234+
}
235+
}
236+
237+
// resolve6(hostname[, callback])
238+
"resolve6" -> ProxyExecutable { args ->
239+
val hostname = args.getOrNull(0)?.asString() ?: return@ProxyExecutable null
240+
val callback = args.getOrNull(1)?.takeIf { it.canExecute() }
241+
return@ProxyExecutable try {
242+
val res = resolve(hostname, true)
243+
if (callback != null) {
244+
callback.execute(null, res)
245+
null
246+
} else res
247+
} catch (t: Throwable) {
248+
if (callback != null) {
249+
callback.execute(t.message ?: t.toString(), null)
250+
null
251+
} else null
252+
}
253+
}
254+
255+
// reverse(ip[, callback])
256+
"reverse" -> ProxyExecutable { args ->
257+
val ip = args.getOrNull(0)?.asString() ?: return@ProxyExecutable null
258+
val callback = args.getOrNull(1)?.takeIf { it.canExecute() }
259+
val res = reverseLookup(ip)
260+
if (callback != null) {
261+
callback.execute(null, res)
262+
null
263+
} else res
264+
}
265+
266+
// stubs for other rrtypes and methods
267+
"lookupService", "resolveAny", "resolveCname", "resolveCaa", "resolveMx",
268+
"resolveNaptr", "resolveNs", "resolvePtr", "resolveSoa", "resolveSrv", "resolveTxt" ->
269+
ProxyExecutable { args ->
270+
val cb = args.lastOrNull()?.takeIf { it?.canExecute() == true }
271+
if (cb != null) {
272+
cb.execute("ENOTSUP", null)
273+
null
274+
} else emptyList<Any>()
275+
}
276+
138277

139278
else -> null
140279
}

0 commit comments

Comments
 (0)