Skip to content

Engines

A JsassCompiler is a thin Java façade over something that runs dart-sass. jsass ships four engines. Three of them run dart-sass's JavaScript build inside the JVM: two on Javet (V8 and Node.js) and one on GraalJS; they take the same ModuleResolver and share one bridge script. The fourth, the embedded engine, runs no JavaScript at all: it starts the native dart-sass executable in a subprocess and talks to it over the Embedded Sass Protocol.

All four implement the same JsassCompiler interface, and one engine-neutral test suite (compile, functions, importers, logger, lifecycle) runs against every one of them. Switching is a change of builder and of a couple of dependencies.

Which engine?

JavetV8JsassCompiler JavetNodeJsassCompiler GraalJsJsassCompiler EmbeddedJsassCompiler
Artifact jsass.javet.v8.compiler jsass.javet.node.compiler jsass.graaljs.compiler jsass.embedded.compiler
Runtime dependency you add com.caoccao.javet:javet-v8-<platform> com.caoccao.javet:javet-node-<platform> org.graalvm.polyglot:js-community (or js) none — but a native sass executable on the machine
Native code Javet library, per platform Javet library, per platform none — pure Java the dart-sass executable, in its own process
Where dart-sass comes from WebjarModuleResolver (usually) NodeModulesResolver (usually) WebjarModuleResolver or NodeModulesResolver the sass executable
Speed fast; native V8 native V8 inside an embedded Node.js JIT-compiled only on a matching GraalVM JDK, slower otherwise fast; dart-sass compiled ahead of time to native code
Platforms Linux, macOS, Windows, Android Linux, macOS, Windows, Android Linux and macOS (Windows not yet) wherever dart-sass ships a release; jsass tests Linux and macOS
JavaScript-only options ✓ ✓ ✓ — (no moduleResolver, autoprefixer, scripts)
Touches the process working directory no yes, with NodeModulesResolver no no

When in doubt, take V8. It has the fewest moving parts: one WebJar dependency, no filesystem layout to get right, and a self-contained JAR at the end. Take GraalJS when you cannot or do not want to ship a native library, or when you already run on GraalVM. Take the embedded engine when you can install a native executable next to your application — a container image, a build agent — and want the reference dart-sass without any JavaScript engine, module resolver or JSON deserializer.

The V8 engine

import io.bit3.jsass.javet.v8.JavetV8JsassCompiler;
import io.bit3.jsass.js.webjars.WebjarModuleResolver;

var compiler = JavetV8JsassCompiler.builder()
    .moduleResolver(WebjarModuleResolver.builder().build())
    .build();

WebjarModuleResolver (artifact jsass.webjar-module-resolver, package io.bit3.jsass.js.webjars) resolves the JavaScript import statements dart-sass makes against WebJar resources on the classpath. Its builder accepts:

Property Default Purpose
jsonDeserializer discovered via ServiceLoader reads each package's package.json to find its entry point — see JSON SPI
classLoader the resolver's own scopes the WebJar scan and reads the resources; for container setups with an isolated classpath
charset UTF-8 encoding of the JavaScript sources

The V8 engine (like GraalJS) also installs a URL polyfill into every runtime, because dart-sass expects the WHATWG URL API that bare V8 does not provide.

The Node engine

import io.bit3.jsass.javet.node.JavetNodeJsassCompiler;
import io.bit3.jsass.js.nodemodules.NodeModulesResolver;

var moduleResolver = NodeModulesResolver.builder()
    .nodeModulesBasePath(Path.of("node_modules").toAbsolutePath())
    .build();

var compiler = JavetNodeJsassCompiler.builder()
    .moduleResolver(moduleResolver)
    .build();

NodeModulesResolver (artifact jsass.node-modules-resolver, package io.bit3.jsass.js.nodemodules) reads a node_modules tree:

Property Default Purpose
nodeModulesBasePath (required) the node_modules directory; a hard boundary
jsonDeserializer discovered via ServiceLoader reads package.json — see JSON SPI
charset UTF-8 encoding of the JavaScript sources

nodeModulesBasePath is a hard boundary: a module name that would resolve outside it fails with SassPathTraversalException rather than falling through to somewhere else on disk.

The Node engine moves the JVM's working directory

In Node mode, Javet uses the name of the script being compiled to set the require() root, the process working directory, __dirname and __filename — documented Javet behaviour. NodeModulesResolver hands it real file-system paths, so the whole JVM process ends up in the directory of the module it loaded last (node_modules/sass for dart-sass) and stays there, even after the compiler is closed. user.dir is untouched, so Path.toAbsolutePath() still reports the old directory, while a relative path handed to a file operation — from any thread — resolves against the new one.

Use absolute paths in code that runs alongside the Node engine. V8, GraalJS, and the Node engine driven by WebjarModuleResolver leave the working directory alone. The fix is tracked as #101.

The GraalJS engine

import io.bit3.jsass.graaljs.GraalJsJsassCompiler;
import io.bit3.jsass.js.webjars.WebjarModuleResolver;

var compiler = GraalJsJsassCompiler.builder()
    .moduleResolver(WebjarModuleResolver.builder().build())
    .build();

GraalJsJsassCompiler (artifact jsass.graaljs.compiler, module io.bit3.jsass.graaljs) runs dart-sass on GraalJS, the JavaScript engine of GraalVM, through the Polyglot API. It depends on org.graalvm.polyglot:polyglot only; you add exactly one JavaScript language at runtime, in the same version: org.graalvm.polyglot:js-community (UPL) or org.graalvm.polyglot:js (GraalVM Free Terms and Conditions). Without one, the builder fails with an IllegalStateException naming both. A module resolver is required, too.

Linux and macOS only

Windows is not yet supported: the engine's virtual module file system relies on Unix-style paths, so the builder fails fast with an IllegalStateException there. Use a Javet engine on Windows. Tracked as #102.

Performance

GraalJS needs no native jar, but it costs about 67 MB of jars (Javet with one native V8 jar is about 14 MB) — and speed, unless you run on GraalVM. Bootstrap 5.3, one compile:

Runtime 1st compile steady state
Node/V8 (reference, ≈ Javet) 0.7 s ~0.28 s
GraalJS on Temurin 21 or 25 (interpreter only) 17 s ~16 s (~55×)
GraalJS on GraalVM CE JDK 25 (JIT) 11–13 s ~1 s after ~50 compiles (~3.5×)

GraalJS is JIT-compiled only on a GraalVM JDK whose Graal version matches js-community; on any other JDK it runs in its interpreter — correct, fine for small stylesheets, roughly 55× slower than V8 on Bootstrap-sized ones. Small stylesheets are fine even interpreted: about 380 ms the first time, then about 20 ms. Loading the engine plus dart-sass takes 2.5–3.3 s once per Polyglot Engine; every further compile gets a fresh Context on it for 40–70 ms.

On a GraalVM JDK, align the js-community version with your JDK's Graal version — jsass pins a polyglot version (25.4.4.1.1), and a different GraalVM resolves the conflict in your build.

The "interpreter only" warning

On a JDK that is not a matching GraalVM, Truffle prints a multi-line warning to stderr when the engine starts, saying GraalJS runs in interpreter-only mode. Silence it with the system property -Dpolyglot.engine.WarnInterpreterOnly=false, or per compiler:

import io.bit3.jsass.graaljs.spi.GraalJsOptions;

var compiler = GraalJsJsassCompiler.builder()
    .moduleResolver(WebjarModuleResolver.builder().build())
    .graaljs(GraalJsOptions.builder().engineOption("engine.WarnInterpreterOnly", "false").build())
    .build();

Sharing an Engine

Loading GraalJS and dart-sass is the expensive part and happens once per Polyglot Engine. By default each compiler creates its own Engine and closes it with the compiler. To share one across compilers — or with the rest of your application — create it yourself and pass it in. jsass never closes an Engine it did not create:

import io.bit3.jsass.graaljs.GraalJsJsassCompiler;
import io.bit3.jsass.graaljs.spi.GraalJsOptions;
import io.bit3.jsass.js.webjars.WebjarModuleResolver;
import org.graalvm.polyglot.Engine;

try (var engine = Engine.newBuilder().option("engine.WarnInterpreterOnly", "false").build();
    var compiler = GraalJsJsassCompiler.builder()
        .moduleResolver(WebjarModuleResolver.builder().build())
        .graaljs(GraalJsOptions.builder().engine(engine).build())
        .build()) {
  // ...
}

Truffle requires every context of one Engine to use the same host access configuration. jsass's contexts use HostAccess.NONE, so your own contexts on a shared Engine must use it too — or every context, jsass's included through a context customizer, must agree on another one.

GraalJsOptions

io.bit3.jsass.graaljs.spi.GraalJsOptions, passed through .graaljs(…), is the only place GraalVM types appear in jsass's API:

Property Builder method(s) Purpose
engine engine(Engine) a shared, caller-owned Polyglot Engine; never closed by jsass
engineOptions engineOption(key, value), engineOptions(Map) options for the Engine jsass creates; ignored when engine is set
contextCustomizers contextCustomizer(Consumer<Context.Builder>), contextCustomizers(…) applied to every Context.Builder after jsass's sandbox settings, so a customizer may loosen them

The sandbox

Every compile runs in a fresh Context, closed afterwards, so startup scripts may change globals without leaking into the next compile. Each context:

  • has no real file system: module imports go through a read-only virtual file system that serves nothing but what the configured ModuleResolver returns, so the JVM's working directory is never touched. Imports can climb at most 16 ../ levels and may not be absolute;
  • sees no Java class (HostAccess.NONE, no host class lookup);
  • cannot create threads or processes, access native code or read the environment.

A context customizer from GraalJsOptions runs after these settings and may loosen them, at your own risk.

The embedded engine

import io.bit3.jsass.embedded.EmbeddedJsassCompiler;

var compiler = EmbeddedJsassCompiler.builder()
    .sassExecutable(Path.of("/opt/dart-sass/sass"))
    .build();

EmbeddedJsassCompiler (artifact jsass.embedded.compiler, module io.bit3.jsass.embedded) runs the native dart-sass executable as sass --embedded in a subprocess and exchanges protobuf messages with it over stdin and stdout, as the Embedded Sass Protocol defines. It depends on jsass, protobuf-java and SLF4J only; no JavaScript engine, no Javet, no GraalVM.

Finding the executable

jsass ships no dart-sass binary (that is planned as #103). Download a release for your platform from sass/dart-sass — or install it through your package manager — and tell jsass where it is. The first of these wins:

  1. sassExecutable(Path) on the builder,
  2. the system property jsass.embedded.sass-executable,
  3. sass on the PATH.

A source that is configured but wrong — a path that does not exist — fails instead of falling through to the next one. Building the compiler starts nothing; the first compile starts dart-sass, and fails with a JsassCompilationException when no usable executable is found.

Point jsass at the sass launcher of a release archive: it is resolved to the bundled Dart VM and sass.snapshot, so jsass starts and stops the Dart process itself rather than a wrapper script. The npm package sass is the pure-JavaScript build and has no --embedded mode; it does not work here, and jsass says so when it finds one.

Supported dart-sass versions

The engine speaks protocol 3.x, which dart-sass speaks from 1.79.1 on; 1.78.0 and older still speak protocol 2 and fail the compile with a message naming the protocol version found and the ones jsass supports. jsass is tested against 1.86.3. Before the first compile jsass asks the executable for its protocol version (sass --embedded --version) and picks a matching protocol driver, so a future protocol 4 can be supported alongside 3.x rather than instead of it.

One process per compiler

All compiles of one compiler share one dart-sass process, multiplexed over its stdin and stdout; starting the process is the expensive part and happens once. Keep the compiler for the lifetime of your application, as with every engine. close() waits for running compiles (up to a minute), then ends the process.

The protocol cannot cancel a single compile. A compile that times out, or whose future you cancel while it runs, therefore fails at once and retires its process: the process accepts no new compiles and ends when its remaining compiles are done, while the next compile starts a fresh process. A runaway stylesheet thus costs one process restart. With a very long timeout and a cancelled runaway compile, the retired process lives until that compile finishes or times out.

If dart-sass dies, the compiles running on it fail and the next compile starts a new process.

Callbacks

Importers, custom functions and the logger work exactly as on the JavaScript engines; dart-sass asks for them over the protocol. The callbacks of one compile run in order on the compiler's executor, so every @warn and @debug has reached your SassLogger before the compile's future completes. Callbacks of different compiles run in parallel.

Differences from the JavaScript engines

  • Load paths are dart-sass's own. They are passed to dart-sass as native load paths rather than resolved by jsass. Native load paths do not confine ..: @use "../x" against a load path lib/ loads x next to lib/, where the JavaScript engines skip that load path. Do not rely on a load path as a security boundary on this engine; use an importer for that.
  • compilePath resolves relative imports of sibling files on disk, which the JavaScript engines currently cannot (#105).
  • charset("false") turns the @charset declaration off; the JavaScript engines treat any non-blank string as true. Unset and blank mean true on every engine (options).
  • No JavaScript-only options: there is no moduleResolver, autoprefixer or scripts, and no engine-level options object.

Module resolvers

A ModuleResolver (io.bit3.jsass.js.ModuleResolver, artifact jsass.js) locates the JavaScript modules dart-sass and its companions import at compile time. It works the same on V8, Node and GraalJS; the embedded engine has no use for one. jsass ships two — WebjarModuleResolver and NodeModulesResolver above — and you can write your own.

The two-step shape mirrors the Sass importer:

public interface ModuleResolver {
  @Nullable String canonicalize(String specifier, @Nullable String referrer) throws IOException;
  String load(String canonicalName) throws IOException;
}
  • canonicalize maps an import specifier to a canonical, resolver-defined name, or returns null if the resolver does not know it. referrer is the canonical name of the importing module only for a relative specifier (./… or ../…); for a bare specifier such as sass it is always null, on every engine (and it is also null when the importing module carries no name).
  • load returns the source text for a name canonicalize returned.

jsass compiles every source a resolver returns as an ES module and caches it per runtime under its canonical name, so identical modules reached through different specifiers are evaluated once. Every compile needs the sass specifier to resolve; postcss and autoprefixer are needed when autoprefixing is on. If one cannot be resolved or loaded, the compile fails with a JsassCompilationException naming the specifier and how to make it resolvable.

A custom resolver usually handles a few specifiers itself and delegates the rest. This one, from examples/autoprefixer, serves a bundled postcss / autoprefixer from the classpath and hands everything else — sass above all — to a WebjarModuleResolver:

BundledCssToolchainResolver.java
import io.bit3.jsass.js.ModuleResolver;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import org.jspecify.annotations.Nullable;

public final class BundledCssToolchainResolver implements ModuleResolver {

  private static final String BUNDLE_ROOT = "/io/bit3/jsass/examples/autoprefixer/bundle/";
  private static final String BUNDLE_PREFIX = "bundle:";

  private final ModuleResolver delegate;

  public BundledCssToolchainResolver(ModuleResolver delegate) {
    this.delegate = delegate;
  }

  @Override
  public @Nullable String canonicalize(String specifier, @Nullable String referrer)
      throws IOException {
    if ("postcss".equals(specifier)) {
      return BUNDLE_PREFIX + "postcss.mjs";
    }
    if ("autoprefixer".equals(specifier)) {
      return BUNDLE_PREFIX + "autoprefixer.mjs";
    }
    if (specifier.startsWith("./") && referrer != null && referrer.startsWith(BUNDLE_PREFIX)) {
      return BUNDLE_PREFIX + specifier.substring(2);
    }
    return delegate.canonicalize(specifier, referrer);
  }

  @Override
  public String load(String canonicalName) throws IOException {
    if (!canonicalName.startsWith(BUNDLE_PREFIX)) {
      return delegate.load(canonicalName);
    }
    String resource = BUNDLE_ROOT + canonicalName.substring(BUNDLE_PREFIX.length());
    try (InputStream in = BundledCssToolchainResolver.class.getResourceAsStream(resource)) {
      if (in == null) {
        throw new FileNotFoundException("Bundle resource " + resource + " is missing");
      }
      return new String(in.readAllBytes(), StandardCharsets.UTF_8);
    }
  }
}

Resolvers carry no engine state and need no dependency on Javet or GraalVM.

Read your resources from your own module

On the module path, Class.getResourceAsStream decides resource encapsulation by the calling module. Read packaged resources with a class from the module that owns them, as above — not through a helper living in another module.

Autoprefixer

autoprefixer(true) on the builder of a JavaScript engine — V8, Node or GraalJS — pipes the compiled CSS, and its source map when one is requested, through PostCSS with Autoprefixer before any of your own result callbacks:

try (var compiler = JavetV8JsassCompiler.builder()
    .moduleResolver(new BundledCssToolchainResolver(WebjarModuleResolver.builder().build()))
    .autoprefixer(true)
    .build()) {
  // .a { user-select: none; }  ->  -webkit-user-select, -moz-user-select, user-select
}

You supply postcss and autoprefixer as ES modules. jsass does not bundle them: that would pin both versions for every consumer. Nor can it use the published artifacts as they are — jsass compiles every module its ModuleResolver returns as an ES module, while the npm and WebJar distributions are CommonJS. Autoprefixer has no ESM entry point at all, and postcss's lib/postcss.mjs only re-exports the CommonJS lib/postcss.js, so importing it yields SyntaxError: The requested module './postcss.js' does not provide an export named 'default'.

So bundle both packages yourself — with rollup, esbuild or whatever your frontend build already uses — and return that bundle from your ModuleResolver for the postcss and autoprefixer specifiers, as BundledCssToolchainResolver does. Everything else, sass included, keeps coming from wherever it came from before. examples/autoprefixer is the complete recipe: the rollup config, the Gradle task that runs it, and the resolver. When either specifier is missing, or resolves to something that is not an ES module, the compile fails with a JsassCompilationException that says so.

Shared builder options

The builders share these methods:

Builder method V8 Node GraalJS Embedded Purpose
defaultTimeout(…) ✓ ✓ ✓ ✓ the timeout when StringOptions.timeout is unset
executor(…) ✓ ✓ ✓ ✓ runs the compiles — see executor
moduleResolver(…) ✓ ✓ ✓ where dart-sass (and every module it imports) comes from
autoprefixer(…) ✓ ✓ ✓ PostCSS + Autoprefixer after the compile
scripts(JsScriptOptions) ✓ ✓ ✓ startup scripts, a compile script, result callbacks — see scripts
javet(JavetOptions) ✓ ✓ Javet-level options
graaljs(GraalJsOptions) ✓ GraalJS-level options
sassExecutable(Path) ✓ the dart-sass executable

Timeouts

Every compile is bounded. The effective limit is resolved in this order:

  1. StringOptions.timeout for this one compile,
  2. the builder's defaultTimeout,
  3. 30 seconds, the built-in floor.

Exceeding it fails the future with JsassCompilationTimeoutException. Both values must be positive; a zero or negative Duration is rejected when the builder or the options object is constructed.

GraalJS in interpreter mode

Bootstrap takes about 17 seconds on an interpreted GraalJS, uncomfortably close to the 30-second default. Raise defaultTimeout for stylesheets of that size.

Executor

By default the compiler creates and owns a fixed thread pool: on the JavaScript engines as many threads as a Javet engine pool allows runtimes, or as many as there are CPUs otherwise; on the embedded engine, which mostly waits on dart-sass, twice the number of CPUs. Either way it is capped at 64. Pass your own with executor(…) to run compiles on an application-managed pool — an injected executor is not shut down by close(), since jsass does not own it.

Scripts

scripts(JsScriptOptions) takes io.bit3.jsass.js.spi.JsScriptOptions (artifact jsass.js), the same on every JavaScript engine. It has three parts, all optional:

Property Builder method(s) What it is
startupScripts startupScript(…), startupScripts(…) StartupScripts loaded on every fresh runtime after dart-sass has been imported, in order
compileScript compileScript(String) replaces the default compile script
resultCallbacks resultCallback(String), resultCallbacks(…) JavaScript functions chained onto the compile result
import io.bit3.jsass.js.spi.JsScriptOptions;
import io.bit3.jsass.js.spi.PlainStartupScript;

var scripts = JsScriptOptions.builder()
    .startupScript(PlainStartupScript.builder()
        .script("globalThis.banner = '/* built with jsass */';")
        .resourceName("virtual:banner.js")
        .build())
    .resultCallback("r => ({...r, css: globalThis.banner + '\\n' + r.css})")
    .build();

var compiler = JavetV8JsassCompiler.builder()
    .moduleResolver(WebjarModuleResolver.builder().build())
    .scripts(scripts)
    .build();

Startup scripts

A StartupScript is typically an ES module that publishes something on globalThis. Two implementations exist in io.bit3.jsass.js.spi:

  • PlainStartupScript.builder().script(…).resourceName(…).isModule(…) — a script string; isModule defaults to true, false evaluates it as a plain script.
  • ResourceStartupScript.of(MyClass.class::getResourceAsStream, "/com/example/startup.js") — a classpath resource, read through an opener created in the module that owns it and an absolute resource path (an overload also takes a Charset and the isModule flag). On the module path a lookup made inside jsass would not see your module's resources, which is why jsass takes an opener instead of a class.

Startup scripts run after dart-sass has loaded. For work that has to happen before — on the Javet engines — use a runtime customizer.

The compile script

The compile script is a JavaScript expression yielding a Promise of the compile result. The default is:

jsass.compile(globalThis.source, globalThis.options, globalThis.jsassHost)

It is evaluated with these globals in scope:

  • globalThis.source — the SCSS source text;
  • globalThis.options — jsass's encoded plain options: a plain object, not dart-sass's options (importers and functions are indexes and signatures, the logger a flag), to be turned into dart-sass options by jsass.toSassOptions;
  • globalThis.jsassHost — the host callbacks those options call back into;
  • globalThis.sass — the dart-sass module, and globalThis.jsass — jsass's bridge.

A custom script must resolve to {css, sourceMap, loadedUrls}, where sourceMap is a JSON string or null and loadedUrls are strings. A working recipe:

new Promise((resolve) => {
  const r = globalThis.sass.compileString(
      globalThis.source,
      jsass.toSassOptions(globalThis.options, globalThis.jsassHost));
  resolve({
    css: r.css,
    sourceMap: r.sourceMap == null ? null : JSON.stringify(r.sourceMap),
    loadedUrls: Array.from(r.loadedUrls).map(String),
  });
})

Result callbacks

Each result callback is a JavaScript function expression, chained with .then(…) onto the compile promise in order. It receives the previous result — at first the compile result {css, sourceMap, loadedUrls} described above — and returns the next result of the same shape, or a Promise of it. The last one's value is what jsass reads. With autoprefixer(true), autoprefixing runs before your callbacks.

Javet options

The Javet engines take io.bit3.jsass.javet.spi.JavetOptions through .javet(…). It is the only package whose signatures mention Javet types. Every field is optional:

Property Builder method(s) Purpose
enginePool enginePool(IJavetEnginePool) a caller-owned pool of runtimes — see engine pools
javetLogger javetLogger(IJavetLogger) replaces the SLF4J bridge jsass installs by default
runtimeCustomizers runtimeCustomizer(…), runtimeCustomizers(…) run on every fresh runtime before any script, so before dart-sass loads
moduleResolver moduleResolver(IV8ModuleResolver) a raw Javet resolver for callers that compile V8 modules themselves; mutually exclusive with the facade's moduleResolver(ModuleResolver)

Logging

Javet's own diagnostics go to SLF4J (logger name sass) without any configuration. Pass a javetLogger in JavetOptions to route them elsewhere. This is separate from the Sass logger, which reports @warn and @debug from your stylesheets.

Runtime customizers

A JavetRuntimeCustomizer runs against every JavaScript runtime right after it is created — before dart-sass loads — the hook for injecting globals or polyfills:

import io.bit3.jsass.javet.spi.JavetOptions;
import io.bit3.jsass.javet.spi.JavetRuntimeCustomizer;

JavetRuntimeCustomizer banner =
    runtime -> runtime.getExecutor("globalThis.__JSASS_BANNER__ = 'v8';").executeVoid();

var compiler = JavetV8JsassCompiler.builder()
    .moduleResolver(WebjarModuleResolver.builder().build())
    .javet(JavetOptions.builder().runtimeCustomizer(banner).build())
    .build();

The GraalJS counterpart is a context customizer in GraalJsOptions.

Engine pools

Without a pool, every Javet compile creates a fresh JavaScript runtime and closes it again when the future completes. That is the safe default — no state survives between compiles — but it also means dart-sass is loaded and evaluated from scratch every single time, and the per-runtime cache of compiled modules is thrown away with it.

An engine pool reuses runtimes across compiles, so the Sass modules are compiled once per runtime instead of once per compile:

import com.caoccao.javet.enums.JSRuntimeType;
import com.caoccao.javet.interop.V8Runtime;
import com.caoccao.javet.interop.engine.JavetEngineConfig;
import com.caoccao.javet.interop.engine.JavetEnginePool;
import io.bit3.jsass.javet.spi.JavetOptions;

var config = new JavetEngineConfig();
config.setJSRuntimeType(JSRuntimeType.V8);
config.setPoolMinSize(1);
config.setPoolMaxSize(4);

try (var pool = new JavetEnginePool<V8Runtime>(config);
     var compiler = JavetV8JsassCompiler.builder()
         .moduleResolver(moduleResolver)
         .javet(JavetOptions.builder().enginePool(pool).build())
         .build()) {
  // …
}

The pool's JSRuntimeType has to match the engine — a Node pool handed to the V8 compiler is rejected with IllegalArgumentException at construction time. The pool is yours: close() on the compiler never closes it, so close it yourself, after the compiler.

Javet's engine configuration disables code generation from strings by default, which dart-sass needs while its module graph evaluates; jsass enables it (allowEval(true)) on every runtime it uses, pooled or not.

GraalJS has no pool: there, sharing an Engine is what makes repeated compiles cheap.