Skip to content

Architecture

Warning

Jsass 6 is still in the early stages of development. API breaks are to be expected!

jsass 6 is a stack of small, replaceable pieces. The interface at the top is what your code sees; everything below it can be swapped without touching a single call site.

flowchart TB
  app["Your application"]
  api["jsass — JsassCompiler, StringOptions, Output, SPIs"]
  v8c["jsass.javet.v8.compiler — JavetV8JsassCompiler"]
  nodec["jsass.javet.node.compiler — JavetNodeJsassCompiler"]
  graalc["jsass.graaljs.compiler — GraalJsJsassCompiler"]
  embc["jsass.embedded.compiler — EmbeddedJsassCompiler"]
  proc["dart-sass executable — sass --embedded, in a subprocess"]
  javet["jsass.javet — Javet adapter, JavetOptions"]
  graal["GraalJS adapter — virtual module file system, GraalJsOptions"]
  core["jsass.js — JsCompilerCore, host callbacks, jsass-bridge.js"]
  webjar["WebjarModuleResolver"]
  nodemod["NodeModulesResolver"]
  sass["dart-sass — the sass JavaScript library"]

  app --> api
  api --> v8c
  api --> nodec
  api --> graalc
  api --> embc
  embc -- "Embedded Sass Protocol (protobuf over stdin/stdout)" --> proc
  v8c --> javet
  nodec --> javet
  graalc --> graal
  javet --> core
  graal --> core
  core --> webjar
  core --> nodemod
  webjar --> sass
  nodemod --> sass
  • jsass is the API and knows no engine.
  • The engine facades are thin builders. The three JavaScript ones pick an engine adapter and hand it to the shared core in jsass.js; the embedded one owns its own core, which talks to a dart-sass process instead (see below).
  • The engine adapters — jsass.javet for both Javet engines, the internal GraalJS adapter in jsass.graaljs.compiler — do only four things: move plain data in both directions, expose the host callbacks as JavaScript functions, wait for the result promise to settle, and terminate a running compile.
  • jsass.js holds everything engine-neutral: JsCompilerCore (executor, timeouts, cancellation, close()), the host callbacks, the ModuleResolver contract and every JavaScript script jsass runs. dart-sass's JS API is known in exactly one place, jsass-bridge.js, which is identical on all engines.
  • Module resolvers feed dart-sass (and, with autoprefixer(true), postcss and autoprefixer) into whichever engine is running.

The compiler interface

JsassCompiler is the only type your application needs to depend on. It has three methods — compileString, compilePath, close — and four implementations behind them.

classDiagram
  class JsassCompiler
  <<interface>> JsassCompiler
  JsassCompiler : +compileString(String source, StringOptions options) CompletableFuture~Output~
  JsassCompiler : +compilePath(Path path, StringOptions options) CompletableFuture~Output~
  JsassCompiler : +close() void

  class Output
  Output : String css
  Output : String sourceMap
  Output : List~URI~ loadedUrls

  class JavetV8JsassCompiler
  JsassCompiler <|-- JavetV8JsassCompiler

  class JavetNodeJsassCompiler
  JsassCompiler <|-- JavetNodeJsassCompiler

  class GraalJsJsassCompiler
  JsassCompiler <|-- GraalJsJsassCompiler

  class EmbeddedJsassCompiler
  JsassCompiler <|-- EmbeddedJsassCompiler

What happens during a compile

On the three JavaScript engines:

sequenceDiagram
  participant App as Your code
  participant C as JsCompilerCore
  participant E as Engine adapter
  participant B as jsass-bridge.js
  participant S as dart-sass

  App->>C: compileString(source, options)
  C-->>App: a pending future
  C->>E: acquire a runtime / context (fresh, pooled, or on a shared Engine)
  E->>E: load dart-sass via the module resolver, run startup scripts
  C->>B: source + options encoded as plain data, host callbacks
  B->>S: compileString with dart-sass options
  S-->>B: an importer, function or logger is needed
  B->>C: host callback with plain data (canonicalize / load / callFunction / log)
  C-->>B: plain data back (stylesheet, Sass value as a tagged map)
  S-->>B: compile result
  B-->>C: plain {css, sourceMap, loadedUrls}
  C-->>App: complete the future

The compile runs on the compiler's executor, guarded by a timeout. Only plain data crosses the Java/JavaScript boundary — null, booleans, numbers, strings, lists and string-keyed maps. Behaviour crosses it as a small set of host callbacks: when dart-sass needs an importer, a custom function or the logger, jsass-bridge.js calls back into Java with plain data, and Sass values travel as tagged maps that the bridge turns into dart-sass objects and back. Because of that, the engine adapters know nothing about Sass, and the three JavaScript engines behave identically.

When a callback fails — an importer or function throws — the typed exception completes the future first, then the error is thrown back into JavaScript so dart-sass unwinds. Logger failures are logged and ignored.

Backends

JavetV8JsassCompiler

The JavetV8JsassCompiler backend uses Javet in V8 mode to run dart-sass. Modules are ECMAScript modules resolved through the ModuleResolver, typically from the classpath, so everything ships inside JARs. A URL polyfill is installed into each runtime because bare V8 has no WHATWG URL implementation.

JavetNodeJsassCompiler

The JavetNodeJsassCompiler backend uses Javet in Node mode. Modules typically come from a node_modules/ directory. jsass sets each module's script name, __dirname, __filename and require() root itself, so the process working directory stays put unless you opt in to Javet's native behaviour — see engines.

GraalJsJsassCompiler

The GraalJsJsassCompiler backend uses GraalJS through the GraalVM Polyglot API — pure Java, no native library. Every compile runs in a fresh, sandboxed Context on one Polyglot Engine; modules are served through a read-only virtual file system backed by the ModuleResolver, so the real file system and the working directory are never touched. It is fast on a matching GraalVM JDK and interpreted elsewhere — see engines.

EmbeddedJsassCompiler

The EmbeddedJsassCompiler backend runs no JavaScript. It starts the native dart-sass executable as sass --embedded and speaks the Embedded Sass Protocol to it: length-delimited protobuf messages over the process's stdin and stdout. That gives it the smallest in-JVM footprint, at the price of a native executable that has to be present on the machine.

sequenceDiagram
  participant App as Your code
  participant C as Embedded core
  participant P as dart-sass process

  App->>C: compileString(source, options)
  C-->>App: a pending future
  C->>P: CompileRequest (start the process first if there is none)
  P-->>C: CanonicalizeRequest / ImportRequest / FunctionCallRequest
  C->>C: run your importer or function on the executor
  C-->>P: the matching response
  P-->>C: LogEvent (@warn, @debug) → your SassLogger
  P-->>C: CompileResponse
  C-->>App: complete the future

One process serves every compile of a compiler; each compile has its own id on the shared stream. The callbacks of one compile run in order, so log events reach the logger before the future completes. Which protocol version the executable speaks is probed once (sass --embedded --version) and selects a protocol driver: the protocol-neutral part — process supervision, compile bookkeeping, timeouts — does not change when a new protocol major arrives, a new driver is added next to the existing one. Details are on the engines page.

Design decisions worth knowing

  • jsass never bundles dart-sass. The Sass version is a dependency of your build, so upgrading Sass does not wait for a jsass release.
  • Files are resolved in Java, not by dart-sass, on every engine: load paths and the relative loads of a file: entry. dart-sass's own file lookup needs an API that only exists in the Node runtime, and its native load paths do not confine ... One implementation of Sass's file rules therefore behaves identically on V8, Node, GraalJS and the embedded engine.
  • Every compile is bounded. A runaway stylesheet cannot pin a thread forever; the effective timeout defaults to 30 seconds.
  • Error messages are sanitized by default. getMessage() never leaks absolute filesystem paths or engine internals — the structured getters carry the full diagnostics.
  • Nothing is closed that jsass does not own. An injected executor, a caller-supplied Javet engine pool, a caller-supplied Polyglot Engine and Javet's V8Host singleton all survive close(). The dart-sass process the embedded engine started does not.
  • One bridge, three JavaScript engines. Everything that knows dart-sass's JavaScript API lives in jsass-bridge.js. Everything that knows the Embedded Sass Protocol lives in the embedded engine's protocol driver.
  • One behaviour suite, four engines. The engine-neutral tests — compile, functions, importers, logger, lifecycle — run against every engine; only tests for JavaScript-only options are limited to the three JavaScript engines.