Skip to content

Add JsInterpreter to replace Rhino usage#2812

Merged
fire-light42 merged 24 commits into
recloudstream:masterfrom
Luna712:jsinterpreter
Jul 8, 2026
Merged

Add JsInterpreter to replace Rhino usage#2812
fire-light42 merged 24 commits into
recloudstream:masterfrom
Luna712:jsinterpreter

Conversation

@Luna712

@Luna712 Luna712 commented May 20, 2026

Copy link
Copy Markdown
Contributor

I added 398 test cases for this, all of which pass locally. When adding tests I specifically targeted some common uses of rhino by extensions, and additionally added things extensions may want or need to use in the future. We can also expand those tests should something else come up in the future.

@Luna712 Luna712 marked this pull request as ready for review May 25, 2026 18:54
@Luna712

Luna712 commented May 25, 2026

Copy link
Copy Markdown
Contributor Author

This should now be ready for review. I ran all the tests and they worked, fixed a couple things, and added a simpler API.

@fire-light42 fire-light42 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like the JS interpreter does not handle the JSFuck obfuscation technique, which abuses easily overlooked parts of standard JS to obfuscate code, it should be very easy to generate test cases.

Would it be possible to support this or would it be too much scope creep? Even if it cannot be fully supported it might reveal some easy-to-fix edge cases.

@Luna712

Luna712 commented Jun 11, 2026

Copy link
Copy Markdown
Contributor Author

It should be possible to support it although I am not entirely sure how at the moment. I aimed for this to support a lot of the standard JS spec, and didn't really go beyond it. I tried to support everything I found extensions using Rhino for and added test cases for some of those uses. I think if we do support it, we can, but perhaps should be a separate PR as that is kinda a different scope then what this was aiming for but up to you, I could look more into this this weekend though if you want.

@fire-light42

Copy link
Copy Markdown
Collaborator

I will also do some testing on the real-world usage of the JS helpers to determine if this is required. Currently it does not seem like a blocker.

@Luna712

Luna712 commented Jun 12, 2026

Copy link
Copy Markdown
Contributor Author

Supporting this does expose other edge case bugs here. I have a patch I will push soon that will support this, fix numerous other things, and add more than 150 new test cases for the edge case bugs and JSFuck format.

@Luna712

Luna712 commented Jun 13, 2026

Copy link
Copy Markdown
Contributor Author

I do want to note also, I did not want to reinvente JavaScript here so this definitely does not support everything, if something needs some other support added we can add it later. I decided to add support for instanceof in my recent commit here only because I found that one could have some use for some scripts potentially and we support typeof. But it still doesn't support some things like some less used Math functions, static functions like Array.isArray, Array.of, etc.... I could easily add it later if it's something we need or want though. Things like console.* are just silently swallowed no-ops so they don't cause noise but also don't fail the scripts. This can all be changed later if necessary. This is designed to be lighter weight, so I didn't want to do everything.

@Luna712 Luna712 requested a review from fire-light42 June 13, 2026 20:07

@fire-light42 fire-light42 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Everything looks good, the only real issue is a possible StackOverflowError. We can catch it easily, but not sure if we can (or should) mitigate it more.

After this I would say it's ready to merge.

Comment thread library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/JsInterpreter.kt Outdated
@Luna712 Luna712 requested a review from fire-light42 June 17, 2026 23:07

@fire-light42 fire-light42 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tested this with the "js is weird" website. While it is not expected to pass all these tests, it might be interesting to check out some edge cases you might have forgotten about like octal (0123) and pow (**) if it is not too much of a bother.

However, I really wanted to ask for a way to limit the execution of JS to something reasonable like 5s, or x number of instructions, as otherwise we might burn the cpu if they have some infinite loop.

    @Test
    fun inf() {
        // We should be handle to exit long-running tasks by quiting
        assertEquals(evalJs("while(true){}"), Unit)
    }

The main reason for this is that we use withTimeout to cancel long running jobs, but this implementation will not acknowledge this behavior and burn the cpu until restart. Most/all of the time JS is not written by the developer, but an untrusted source, so we can not expect good code.

  @Test
    fun inf_suspend() {
        runBlocking {
            // We should be handle to exit long-running tasks by quiting
            withTimeout(100) {
                evalJs("while(true){}")
            }
            assertEquals(1, 1)
        }
    }
    @Test
    fun weird1() {
        assertEquals(1.0, num("true + false"))
    }
    @Test
    fun weird2() {
        assertEquals(3.0, num("[,,,].length"))
    }
    @Test
    fun weird2_2() {
        assertEquals(2.0, num("[1,2,].length"))
    }
    @Test
    fun weird2_3() {
        assertEquals(3.0, num("[1,2,,].length"))
    }
    @Test
    fun weird3() {
        assertEquals("1,2,34,5,6", evalJs("[1, 2, 3] + [4, 5, 6]"))
    }
    @Test
    fun weird4() {
        assertEquals(2.0, num("10,2"))
    }
    @Test
    fun weird5() {
        assertEquals(false,evalJs("!!\"\""))
    }
    @Test
    fun weird6() {
        assertEquals(1.0, num("+!![]"))
    }
    @Test
    fun weird7() {
        // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/parseInt
        assertEquals(5.0, num("parseInt(0.0000005)"))
    }
    @Test
    fun weird8() {
        assertEquals(false, evalJs("true == \"true\""))
    }
    @Test
    fun weird9() {
        // Octal
        assertEquals(5.0, num("010 - 03"))
    }
    @Test
    fun weird10() {
        assertEquals(0.0, evalJs("\"\" - - \"\""))
    }
    @Test
    fun weird11() {
        assertEquals(0.0, num("null + 0"))
    }
    @Test
    fun weird12() {
        assertEquals(num("NaN"), evalJs("0/0"))
    }
    @Test
    fun weird13() {
        // Pow
        assertEquals(num("Infinity"), evalJs("10 ** 1000"))
        assertEquals(false, evalJs("1/0 > 10 ** 1000"))
    }
    @Test
    fun weird14() {
        // inc vs add
        assertEquals(2.0, evalJs("true+1"))
        assertEquals(Unit, evalJs("true++"))
    }
    @Test
    fun weird15() {
        assertEquals(-1.0, evalJs("\"\" - 1"))
    }
    @Test
    fun weird16() {
        assertEquals("00", evalJs("(null - 0) + \"0\""))
    }
    @Test
    fun weird17() {
        assertEquals(Double.NaN, evalJs("true + (\"true\" - 0) "))
    }
    @Test
    fun weird18() {
        assertEquals(0.0, evalJs("!5 + !5"))
    }
    @Test
    fun weird19() {
        assertEquals("", evalJs("[] + []"))
    }
    @Test
    fun weird20() {
        assertEquals("33", evalJs("1 + 2 + \"3\""))
    }
    @Test
    fun weird21() {
        assertEquals("number", evalJs("typeof NaN"))
    }
    @Test
    fun weird22() {
        assertEquals(Double.NaN, evalJs("undefined + false"))
    }
    @Test
    fun weird23() {
        assertEquals("", evalJs("\"\" && -0"))
    }
    @Test
    fun weird24() {
        assertEquals(0.0, evalJs("+!!NaN * \"\" - - [,]"))
    }

And you passed a supprisingly large amount, with only these failures

weird2
Expected :3.0
Actual   :2.0

weird7
Expected :5.0
Actual   :NaN

weird9
Expected :5.0
Actual   :7.0

weird10
Expected :0.0
Actual   :NaN

weird13
Expected :Infinity
Actual   :1000.0

weird14
Expected :kotlin.Unit
Actual   :1.0

weird15
Expected :-1.0
Actual   :NaN

weird22
Expected :NaN
Actual   :0.0

weird24
Expected :0.0
Actual   :NaN

@Luna712

Luna712 commented Jun 20, 2026

Copy link
Copy Markdown
Contributor Author

I will work on this more tomorrow, though I potentially if you are okay with it, focus on just the timeout stuff, and we can later expand to fix some edge cases in a followup. Though I will try and fix some of them + add tests, just a follow up instead if something is more complicated is what I would prefer if you're okay with it.

@fire-light42

Copy link
Copy Markdown
Collaborator

I will work on this more tomorrow, though I potentially if you are okay with it, focus on just the timeout stuff, and we can later expand to fix some edge cases in a followup. Though I will try and fix some of them + add tests, just a follow up instead if something is more complicated is what I would prefer if you're okay with it.

Yes, the timeout issue is the only real blocker I can see.

@Luna712

Luna712 commented Jun 21, 2026

Copy link
Copy Markdown
Contributor Author

I will work on this more tomorrow, though I potentially if you are okay with it, focus on just the timeout stuff, and we can later expand to fix some edge cases in a followup. Though I will try and fix some of them + add tests, just a follow up instead if something is more complicated is what I would prefer if you're okay with it.

Yes, the timeout issue is the only real blocker I can see.

@fire-light42

I went ahead and fixed all the edge cases you mentioned above as well as added limiting via budget either instruction count or timeout. I think I got it all working and added numerous more tests for it. Hopefully I didn't miss anything else now though.

@Luna712 Luna712 requested a review from fire-light42 June 21, 2026 22:47
@fire-light42

Copy link
Copy Markdown
Collaborator

Very good changes, but this still does not respect the CancellationException that should be invoked by withTimeout/ensureActive.

e.g. this code does not take 100ms to run.

withTimeout(100) {evalJs("while(true){}", maxExecutionTime =  50.seconds, maxInstructions =  50_000_000_000L)}

This is tricky as the function itself is not suspended, and coloring every function suspended is a bit wierd. We might want to do that considering it should never be executed on the main thread. However, I think we can do a compromise and add an optional scope parameter to make the coroutines more cooperative.

This is my solution, but if you want to color it suspended or do some other magic then it would also be good.

Index: library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/JsInterpreter.kt
===================================================================
diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/JsInterpreter.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/JsInterpreter.kt
--- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/JsInterpreter.kt	(revision e9c8adba3122bc936bc223af289e8fc888889d3e)
+++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/JsInterpreter.kt	(date 1782228968073)
@@ -4,6 +4,9 @@
 import com.lagradost.cloudstream3.mvvm.logError
 import com.lagradost.cloudstream3.utils.StringUtils.decodeUrl
 import com.lagradost.cloudstream3.utils.StringUtils.encodeUrl
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.isActive
 import kotlin.math.*
 import kotlin.random.Random
 import kotlin.time.Duration
@@ -117,12 +120,24 @@
     variable: String? = null,
     maxExecutionTime: Duration = JS_DEFAULT_MAX_EXECUTION_TIME,
     maxInstructions: Long = JS_DEFAULT_MAX_INSTRUCTIONS,
+    scope : CoroutineScope? = null,
 ): Any? {
-    val interpreter = JsInterpreter(maxExecutionTime, maxInstructions)
+    val interpreter = JsInterpreter(maxExecutionTime, maxInstructions, scope)
     val result = interpreter.eval(js)
     return if (variable != null) interpreter.getVar(variable) else result
 }
 
+@Prerelease
+fun CoroutineScope.evalJs(
+    js: String,
+    variable: String? = null,
+    maxExecutionTime: Duration = JS_DEFAULT_MAX_EXECUTION_TIME,
+    maxInstructions: Long = JS_DEFAULT_MAX_INSTRUCTIONS,
+): Any? {
+    return evalJs(js,variable,maxExecutionTime,maxInstructions,this)
+}
+
+
 private enum class TT {
     NUMBER, STRING, IDENT, PLUS, MINUS, STAR, SLASH, PERCENT, POW, EQ, EQEQ, EQEQEQ,
     NEQ, NEQEQ, LT, LTEQ, GT, GTEQ, AND, OR, NOT, AMP, PIPE, CARET, TILDE,
@@ -851,6 +866,7 @@
 private class JsInterpreter(
     private val maxExecutionTime: Duration = JS_DEFAULT_MAX_EXECUTION_TIME,
     private val maxInstructions: Long = JS_DEFAULT_MAX_INSTRUCTIONS,
+    private val scope : CoroutineScope? = null,
 ) {
     private val globalScope = Scope()
 
@@ -977,7 +993,9 @@
     fun eval(code: String): Any? {
         instructionCount = 0
         startMark = TimeSource.Monotonic.markNow()
-        return evalInternal(code)
+        val ans = evalInternal(code)
+        this.scope?.ensureActive()
+        return ans
     }
 
     /** Runs [code] against the current budget, without resetting it. */
@@ -1009,7 +1027,12 @@
         }
         // Only sample the clock every 1024 ticks. Calling elapsedNow() on every single
         // statement would add measurable overhead to normal (non-runaway) scripts.
-        if (instructionCount and 0x3FFL == 0L && startMark.elapsedNow() >= maxExecutionTime) {
+        if(instructionCount and 0x3FFL != 0L) return
+        if (this.scope?.isActive == false) {
+            throw JsExecutionLimitExceeded("script has been canceled")
+        }
+
+        if (startMark.elapsedNow() >= maxExecutionTime) {
             throw JsExecutionLimitExceeded("script exceeded max execution time of $maxExecutionTime")
         }
     }

@Luna712

Luna712 commented Jun 23, 2026

Copy link
Copy Markdown
Contributor Author

Thank you for the suggestion. I might just make it suspended as I think that is a more proper solution tbh. I think originally misunderstood what you meant though. I didn't know you wanted the actual withTimeout to control this, just some way to actually limit the time or instruction count with this in particular, using 5s as default.

@Luna712

Luna712 commented Jun 23, 2026

Copy link
Copy Markdown
Contributor Author

@fire-light42 at this point I am thinking of just making a package for this and splitting this more because it has become much larger than I originally intended lol. But not now anyway. Making them suspend is a significant refactor though and will require numerous other changes. Will hopefully be able to finish that soon.

@fire-light42

Copy link
Copy Markdown
Collaborator

Thank you for the suggestion. I might just make it suspended as I think that is a more proper solution tbh. I think originally misunderstood what you meant though. I didn't know you wanted the actual withTimeout to control this, just some way to actually limit the time or instruction count with this in particular, using 5s as default.

No, you understood perfectly. I wanted all three, "instruction count timeout", "wallclock timeout" and "suspend support". However, adding "suspend" gets you "wallclock timeout" for free as you can simply wrap the topmost eval with withTimeout.

@Luna712

Luna712 commented Jun 27, 2026

Copy link
Copy Markdown
Contributor Author

@fire-light42 okay I think I got it. I did not do suspend though. That ended up actually hurting performance quite a lot for everything even when we don't need it. I did your method with some changes. ensureActive where you had it caused it to run the whole thing before checking that sometimes for some reason so I manually handled CancellationException via a new JsCancellationException just so tests could better differ. It took me awhile to figure out how to write tests for coroutines also. That was the primary reason this took so long. I also decided not to have the scope version defer to the other version so that we don't have a scope entry point outside of CoroutineScope and leave that as the only entry point for that. Less confusing for autocompletion too so you don't get a scope parameter when you don't need one. At least in my opinion. If you have other ideas or feel I should do something different I can. I apologize for this taking awhile.


/**
* Scope-aware variant of [evalJs]. The interpreter checks [CoroutineScope.isActive] every
* 1024 instructions and aborts (returning [Unit]) if the scope has been cancelled.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"every 1024 instructions and aborts" This is technically wrong when reading the code.
Also "(returning [Unit]) if the scope has been cancelled", @Throws(CancellationException::class)

@Luna712 Luna712 Jun 30, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whoops sorry. That was leftover from how I had it when first writing the comment then changed it as I kept testing. Will fix that. Thank you very much for pointing that out.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe add a warning or opt-in to the regular evalJs to promote use of CoroutineScope.evalJs?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally I think I should benchmark this more first from my intentional testing it had a little better performance without CoroutineScope, but that just allows better compat with existing code. It was more or less negligible but I am not certain if we should push it as much or not though. I can if you want though.

On a side note I was thinking of writing some benchmark tests for this and a few other things using kotlinx-benchmark but that would be out of scope of this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh wait sorry no I fixed those performance issues already. That was in my initial testing.

@fire-light42

fire-light42 commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

@fire-light42 okay I think I got it. I did not do suspend though. That ended up actually hurting performance quite a lot for everything even when we don't need it.

It is fine, however you can still color the outermost function with coroutineScope or currentCoroutineContext.

suspend fun evalJs(...) {
    coroutineScope { ... }
}

That way you can force the caller to use suspend without sacrificing performance. The reason why we want to force to user to invoke it with suspended do this is to avoid a situation like:

private fun evaluateMath(mathExpression: String): String { return jsValueToString(evalJs("eval($mathExpression)")) }

Where a helper function removes the ability for us to cancel it.

@Luna712

Luna712 commented Jun 30, 2026

Copy link
Copy Markdown
Contributor Author

I am confused what you mean for that. Wouldn't that make CoroutineScope.evalJs version completely redundant? Which I may be misunderstanding (sorry if so) but wouldn't that contradict with the above comment about adding that + "promote use of CoroutineScope.evalJs" which kinda confused me. Sorry about that.

If I understood, correctly anyway: I didn't want to make the main evalJs suspend or like that because we have the other version. Also I will have to spend a ton of time rewriting all (or at least most) tests also, as they are much harder to test in a full coroutine/suspend context due to virtual time and other stuff. I can if you want though but this will probably take a little while to finish I think. Some I may be able to just add runTest but a lot will need completely rewritten. Also it can cause CancellationException more, so we'd have to add checks for that and other stuff making that a much larger change then I really wanted. Still though, if it is what you want I definitely can do it, it will just take awhile before I finish it probably.

@fire-light42

Copy link
Copy Markdown
Collaborator

I am confused what you mean for that. Wouldn't that make CoroutineScope.evalJs version completely redundant? Which I may be misunderstanding (sorry if so) but wouldn't that contradict with the above comment about adding that + "promote use of CoroutineScope.evalJs" which kinda confused me. Sorry about that.

If I understood, correctly anyway: I didn't want to make the main evalJs suspend or like that because we have the other version. Also I will have to spend a ton of time rewriting all (or at least most) tests also, as they are much harder to test in a full coroutine/suspend context due to virtual time and other stuff. I can if you want though but this will probably take a little while to finish I think. Some I may be able to just add runTest but a lot will need completely rewritten. Also it can cause CancellationException more, so we'd have to add checks for that and other stuff making that a much larger change then I really wanted. Still though, if it is what you want I definitely can do it, it will just take awhile before I finish it probably.

After doing a bit more thinking I thought it is just easier to have one public api that is suspended instead of CoroutineScope.evalJs to really make it clear how it should be used. Making the API more restricted (suspend) allows us to change the underlying implement easier. No need to change many tests as we can just import the actual implementation.

Do note that this does not touch "JsContext", which is currently without a scope.

Subject: [PATCH] Force suspend
---
Index: library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Userload.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Userload.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Userload.kt
--- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Userload.kt	(revision 39e08e5f6eb3498c775dd7f7c5194634b7815bfe)
+++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/extractors/Userload.kt	(date 1782841266776)
@@ -29,11 +29,11 @@
         return array
     }
 
-    private fun evaluateMath(mathExpression: String): String {
+    private suspend fun evaluateMath(mathExpression: String): String {
         return jsValueToString(evalJs("eval($mathExpression)"))
     }
 
-    private fun decodeVideoJs(text: String): List<String> {
+    private suspend fun decodeVideoJs(text: String): List<String> {
         text.replace("""\s+|/\*.*?\*/""".toRegex(), "")
         val data = text.split("""+(゚Д゚)[゚o゚]""")[1]
         val chars = data.split("""+ (゚Д゚)[゚ε゚]+""").drop(1)
Index: library/src/commonTest/kotlin/com/lagradost/cloudstream3/utils/JsInterpreterTest.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/library/src/commonTest/kotlin/com/lagradost/cloudstream3/utils/JsInterpreterTest.kt b/library/src/commonTest/kotlin/com/lagradost/cloudstream3/utils/JsInterpreterTest.kt
--- a/library/src/commonTest/kotlin/com/lagradost/cloudstream3/utils/JsInterpreterTest.kt	(revision 39e08e5f6eb3498c775dd7f7c5194634b7815bfe)
+++ b/library/src/commonTest/kotlin/com/lagradost/cloudstream3/utils/JsInterpreterTest.kt	(date 1782842562099)
@@ -1,5 +1,6 @@
 package com.lagradost.cloudstream3.utils
 
+import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.cancel
@@ -20,7 +21,8 @@
 import kotlin.time.Duration.Companion.milliseconds
 import kotlin.time.Duration.Companion.seconds
 import kotlin.time.TimeSource
-
+import com.lagradost.cloudstream3.utils.evalJsInternal as evalJs
+import com.lagradost.cloudstream3.utils.evalJs as evalJsSuspended
 class JsInterpreterTest {
 
     private fun bool(code: String, variable: String? = null): Boolean = evalJs(code, variable) as? Boolean ?: false
@@ -2075,7 +2077,7 @@
     fun scopeEvalJsFiniteScriptReturnsCorrectResult() {
         // Normal script with an active scope should behave identically to plain evalJs.
         val scope = activeScope()
-        val result = scope.evalJs("var s=0; for(var i=1;i<=10;i++){s+=i}", "s")
+        val result = evalJs("var s=0; for(var i=1;i<=10;i++){s+=i}", "s",scope = scope)
         assertEquals(55.0, result as? Double ?: 0.0)
         scope.cancel()
     }
@@ -2083,7 +2085,7 @@
     @Test
     fun scopeEvalJsStringResultWithActiveScope() {
         val scope = activeScope()
-        val result = jsValueToString(scope.evalJs("'hello'.split('').reverse().join('')"))
+        val result = jsValueToString(evalJs("'hello'.split('').reverse().join('')",scope = scope))
         assertEquals("olleh", result)
         scope.cancel()
     }
@@ -2091,7 +2093,7 @@
     @Test
     fun scopeEvalJsVariableLookupWithActiveScope() {
         val scope = activeScope()
-        val result = scope.evalJs("var x = 21 * 2", "x")
+        val result = evalJs("var x = 21 * 2", "x",scope = scope)
         assertEquals(42.0, result as? Double ?: 0.0)
         scope.cancel()
     }
@@ -2103,7 +2105,7 @@
         val scope = activeScope()
         scope.cancel()
         assertFailsWith<JsCancellationException> {
-            scope.evalJs("var x = 1 + 2", "x")
+            evalJs("var x = 1 + 2", "x",scope = scope)
         }
     }
 
@@ -2116,7 +2118,7 @@
         scope.cancel()
         val mark = TimeSource.Monotonic.markNow()
         assertFailsWith<JsCancellationException> {
-            scope.evalJs("while(true){}")
+            evalJs("while(true){}",scope = scope)
         }
         assertTrue(
             mark.elapsedNow() < 1.seconds,
@@ -2129,7 +2131,7 @@
         // Even with an active (never-cancelled) scope the internal budget still fires.
         val scope = activeScope()
         val mark = TimeSource.Monotonic.markNow()
-        val result = scope.evalJs("while(true){}", maxExecutionTime = 200.milliseconds)
+        val result = evalJs("while(true){}", maxExecutionTime = 200.milliseconds,scope = scope)
         assertEquals(Unit, result)
         assertTrue(mark.elapsedNow() < 2.seconds)
         scope.cancel()
@@ -2144,7 +2146,7 @@
         scope.cancel()
         val mark = TimeSource.Monotonic.markNow()
         assertFailsWith<JsCancellationException> {
-            scope.evalJs("while(true){ try{ throw 1; }catch(e){} }")
+            evalJs("while(true){ try{ throw 1; }catch(e){} }",scope = scope)
         }
         assertTrue(
             mark.elapsedNow() < 1.seconds,
@@ -2160,7 +2162,7 @@
         val scope = activeScope()
         scope.cancel()
         assertFailsWith<JsCancellationException> {
-            scope.evalJs("1+1")
+            evalJs("1+1",scope = scope)
         }
     }
 
@@ -2179,11 +2181,11 @@
         var elapsed = Duration.ZERO
         val done = Channel<Unit>()
         activeScope().launch {
-            assertFailsWith<JsCancellationException> {
+            assertFailsWith<CancellationException> {
                 withTimeout(300.milliseconds) {
                     val mark = TimeSource.Monotonic.markNow()
                     try {
-                        evalJs("while(true){}")
+                        evalJsSuspended("while(true){}")
                     } finally {
                         elapsed = mark.elapsedNow()
                     }
Index: library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/JsInterpreter.kt
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/JsInterpreter.kt b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/JsInterpreter.kt
--- a/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/JsInterpreter.kt	(revision 39e08e5f6eb3498c775dd7f7c5194634b7815bfe)
+++ b/library/src/commonMain/kotlin/com/lagradost/cloudstream3/utils/JsInterpreter.kt	(date 1782842562077)
@@ -6,6 +6,7 @@
 import com.lagradost.cloudstream3.utils.StringUtils.encodeUrl
 import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.coroutineScope
 import kotlinx.coroutines.isActive
 import kotlin.math.E
 import kotlin.math.PI
@@ -129,13 +130,14 @@
  *         JS null is represented as Kotlin null. Use [jsValueToString] to convert to a JS string.
  */
 @Prerelease
-fun evalJs(
+internal fun evalJsInternal(
     js: String,
     variable: String? = null,
     maxExecutionTime: Duration = JS_DEFAULT_MAX_EXECUTION_TIME,
     maxInstructions: Long = JS_DEFAULT_MAX_INSTRUCTIONS,
+    scope : CoroutineScope? = null
 ): Any? {
-    val interpreter = JsInterpreter(maxExecutionTime, maxInstructions)
+    val interpreter = JsInterpreter(maxExecutionTime, maxInstructions, scope)
     val result = interpreter.eval(js)
     return if (variable != null) interpreter.getVar(variable) else result
 }
@@ -153,16 +155,12 @@
  */
 @Prerelease
 @Throws(CancellationException::class)
-fun CoroutineScope.evalJs(
+suspend fun evalJs(
     js: String,
     variable: String? = null,
     maxExecutionTime: Duration = JS_DEFAULT_MAX_EXECUTION_TIME,
     maxInstructions: Long = JS_DEFAULT_MAX_INSTRUCTIONS,
-): Any? {
-    val interpreter = JsInterpreter(maxExecutionTime, maxInstructions, this)
-    val result = interpreter.eval(js)
-    return if (variable != null) interpreter.getVar(variable) else result
-}
+): Any? = coroutineScope { evalJsInternal(js, variable, maxExecutionTime, maxInstructions, this) }
 
 private enum class TT {
     NUMBER, STRING, IDENT, PLUS, MINUS, STAR, SLASH, PERCENT, POW, EQ, EQEQ, EQEQEQ,

@Luna712

Luna712 commented Jun 30, 2026

Copy link
Copy Markdown
Contributor Author

Thank you very much for that. I didn't consider that way to make it easy to fix tests. Thanks a ton for that. I will make the changes later.

@fire-light42

fire-light42 commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

Thank you very much for that. I didn't consider that way to make it easy to fix tests. Thanks a ton for that. I will make the changes later.

I was also thinking of something like this

@Prerelease
class JsContext internal constructor(
    maxExecutionTime: Duration = JS_DEFAULT_MAX_EXECUTION_TIME,
    maxInstructions: Long = JS_DEFAULT_MAX_INSTRUCTIONS,
    scope: CoroutineScope? = null
) {
    private val interpreter = JsInterpreter(maxExecutionTime, maxInstructions, scope)

    /** Evaluate [code] in this context.  Returns the last expression value. */
    suspend fun eval(code: String): Any? = interpreter.eval(code)

    /** Retrieve a variable set by previously evaluated code. */
    operator fun get(name: String): Any? = interpreter.getVar(name)

    /** Expose a Kotlin value to subsequently evaluated JS code. */
    operator fun set(name: String, value: Any?) = interpreter.setVar(name, value)
}

@Prerelease
suspend fun newJsContext(initializer: suspend JsContext.() -> Unit = { }) : JsContext = coroutineScope {
    JsContext(scope = this).apply {
        initializer()
    }
}

This allows for easier context, as you can use it

suspend fun test() {
    newJsContext {
        eval("hello = 1")
        print(get("hello"))
    }
}

or like

suspend fun test() {
    val context = newJsContext()
    context.eval("hello = 1")
    print(context.get("hello"))
}

While keeping the scope

Edit: unsure if we want to have maxExecutionTime/maxInstructions as arguments to newJsContext, as this.maxExecutionTime or on suspend fun eval(code: String): Any

@Luna712

Luna712 commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

@fire-light42 had an issue where it can't run in the existing scope causing a lot of premature cancellations and other bugs so I ended up doing it a little different. Hopefully it properly works now and within what you wanted (can still change if necessary) though I am hoping this can be merged before next stable?

Also, sorry for the delay with this. Took me awhile to debug that issue where it prematurely cancelled and what not also and to properly figure out how to set maxInstructions/maxExecutionTime without changing JsInterpreter.eval which made things a lot more complicated and less reliable by passing things around more so didn't want to do that.

Note: I also found out about VisibleForTesting and used that as I think it was quite useful to be able to communicate what we have specifically for testing also.

@fire-light42 fire-light42 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is precisely what I envisioned for a public and easy JS API. Very impressive and a great job! ❤️❤️❤️

@fire-light42 fire-light42 merged commit 694c9c4 into recloudstream:master Jul 8, 2026
1 of 2 checks passed
@Luna712 Luna712 deleted the jsinterpreter branch July 8, 2026 23:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants