Skip to content

Proposal: Changes to support hosting within larger compilers (esbuild/swc/webpack/rollup) #169

Open
@Adjective-Object

Description

@Adjective-Object

Hey there,

I'm working on a project and am interested in using rsass, hosted inside of a custom esbuild plugin. This would be replacing an existing plugin that is based on libsass

I think I have a rough sketch of what that would entail, and I'd like to pursue it. However, I'm curious if this work is something you'd be interested in upstreaming into rsass, or if it's something I'd be maintaining in a fork indefinitely.

Updates to Rsass

Here's my thought process:

  1. When being used within a larger compiler, The only thing that changes about compilation is how @import / @use strings are resolved to files:
    • For example, in node-sass, the import specifier ~my-node-package/src/colors.sass resolves using node's builtin resolver, reads the file, and passes the result back to libsass
    • Sometimes those files may not exist on disk - they might just be an in-memory module provided by another plugin within the compiler.
    • There needs to be some API to also dependency-inject how import -> filepath mapping is done in a Context.
    • This is true regardless of the language the host compiler is written in
  2. FFI is very expensive (at least for Node/Rust FFI and Go/Rust FFI)
    • We want to minimize the number of times we cross the FFI barrier
    • libsass solves this problem by bundling together the loading/resolution steps into a single callback
    • This doesn't fit with the current design of Loader, which essentially acts as a virtual file system (a single find_file() method).
  3. It makes sense to emulate libsass's approach of bundling together loading/resolution when crossing the FFI barrier
  4. We need to tweak how rsass does import resolution and file loading to accommodate this approach

Actual Changes

What I was thinking of was:

  1. We can replace the Loader in Context with a Resolver trait that looks something like this sketch:
    pub trait Resolver {
        type File: std::io::Read;
    
        fn resolve(self, importer_path: &str, url: &str) -> Result<Option<ResolutionResult<File>>, LoadError>;
    }
    
    struct ResolutionResult<TFile: std::io::Read> {
      path: String,
      content: TFile,
    }
    • This enables hijacking loads, and minimizes calls into the host in FFI scenarios, since resolving and loading are on the same call
  2. We can minimize the footprint of this change by:

These could also be named ResolverLoader (the new API I am describing), and Loader (the existing virtual filesystem)


FFIResolver

Minimizing Rsass -> Host calls during compilation

When consuming rsass in FFI scenarios, my plan was to provide an implementer of the new Resolver trait that performed the actual FFI. To do as little FFI as possible, it makes sense to cache resolutions on the rust-side. I was thinking of using a shared probabalistic cache based on the import + export pairs. This is, again, to minimize the time spent crossing the FFI barrier during compilation

The API presented to C-like languages would be a bit different to support this caching

#[repr(C)]
struct ResolutionResult{
  // or equivalent - This might get done as raw pointers and immediately copied into
  // Strings / u8s as soon as we cross the api boundary into rust
  path: String,
  content: Vec<u8>,

  // Also include a flag saying If this resolution can be reused across
  // multiple importers. Only do this for something like `~some-node-package/foo/bar.scss`
  isImporterIndependentResolution: bool
}

That could then be used to avoid additional FFI round-trips at the cost of some memory overhead.

Minimizing Host -> Rsass calls during incremental compilation

Even if all the files within the compilation of a file are fully cached in the FFIResolver, Crossing the barrier from the host into rust to re-evaluate the compilation is still expensive.

libsass solves this problem by returning the list of resolved files from a compilation I make heavy use of this in my existing libsass-based plugin to check relevant files for changes and use that to short-circuit full compilation results during rebuild requests, to minimize the number of calls into libsass.

I think the FFIResolver is in a reasonable position to do something similar, since it will be intercepting all file resolutions.

To me both of these this seems like it would be an internal concern of the FFIResolver.

However, if there is any interest in supporting hosted compilers / FFI, I think it would make sense to PR in the FFIResolver I'm describing here as well.
If not, I'm happy to maintain it as an implementation detail on a wrapping library / fork

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions