464 lines
11 KiB
Go
464 lines
11 KiB
Go
// Package godartsass provides a Go API for the Dass Sass Embedded protocol.
|
|
//
|
|
// Use the Start function to create and start a new thread safe transpiler.
|
|
// Close it when done.
|
|
package godartsass
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/url"
|
|
"path"
|
|
"strings"
|
|
"time"
|
|
|
|
"os"
|
|
"os/exec"
|
|
"sync"
|
|
|
|
"github.com/cli/safeexec"
|
|
|
|
"github.com/bep/godartsass/internal/embeddedsass"
|
|
"google.golang.org/protobuf/proto"
|
|
)
|
|
|
|
const defaultDartSassEmbeddedFilename = "dart-sass-embedded"
|
|
|
|
// ErrShutdown will be returned from Execute and Close if the transpiler is or
|
|
// is about to be shut down.
|
|
var ErrShutdown = errors.New("connection is shut down")
|
|
|
|
// Start creates and starts a new SCSS transpiler that communicates with the
|
|
// Dass Sass Embedded protocol via Stdin and Stdout.
|
|
//
|
|
// Closing the transpiler will shut down the process.
|
|
//
|
|
// Note that the Transpiler is thread safe, and the recommended way of using
|
|
// this is to create one and use that for all the SCSS processing needed.
|
|
func Start(opts Options) (*Transpiler, error) {
|
|
if err := opts.init(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// See https://github.com/golang/go/issues/38736
|
|
bin, err := safeexec.LookPath(opts.DartSassEmbeddedFilename)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
cmd := exec.Command(bin)
|
|
cmd.Stderr = os.Stderr
|
|
|
|
conn, err := newConn(cmd)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := conn.Start(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
t := &Transpiler{
|
|
opts: opts,
|
|
conn: conn,
|
|
lenBuf: make([]byte, binary.MaxVarintLen64),
|
|
pending: make(map[uint32]*call),
|
|
}
|
|
|
|
go t.input()
|
|
|
|
return t, nil
|
|
}
|
|
|
|
// Transpiler controls transpiling of SCSS into CSS.
|
|
type Transpiler struct {
|
|
opts Options
|
|
|
|
// stdin/stdout of the Dart Sass protocol
|
|
conn byteReadWriteCloser
|
|
lenBuf []byte
|
|
msgBuf []byte
|
|
|
|
closing bool
|
|
shutdown bool
|
|
|
|
// Protects the sending of messages to Dart Sass.
|
|
sendMu sync.Mutex
|
|
|
|
mu sync.Mutex // Protects all below.
|
|
seq uint32
|
|
pending map[uint32]*call
|
|
}
|
|
|
|
// Result holds the result returned from Execute.
|
|
type Result struct {
|
|
CSS string
|
|
SourceMap string
|
|
}
|
|
|
|
// SassError is the error returned from Execute on compile errors.
|
|
type SassError struct {
|
|
Message string `json:"message"`
|
|
Span struct {
|
|
Text string `json:"text"`
|
|
Start struct {
|
|
Offset int `json:"offset"`
|
|
Column int `json:"column"`
|
|
} `json:"start"`
|
|
End struct {
|
|
Offset int `json:"offset"`
|
|
Column int `json:"column"`
|
|
} `json:"end"`
|
|
Url string `json:"url"`
|
|
Context string `json:"context"`
|
|
} `json:"span"`
|
|
}
|
|
|
|
func (e SassError) Error() string {
|
|
span := e.Span
|
|
file := path.Clean(strings.TrimPrefix(span.Url, "file:"))
|
|
return fmt.Sprintf("file: %q, context: %q: %s", file, span.Context, e.Message)
|
|
}
|
|
|
|
// Close closes the stream to the embedded Dart Sass Protocol, shutting it down.
|
|
// If it is already shutting down, ErrShutdown is returned.
|
|
func (t *Transpiler) Close() error {
|
|
t.sendMu.Lock()
|
|
defer t.sendMu.Unlock()
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
|
|
if t.closing {
|
|
return ErrShutdown
|
|
}
|
|
|
|
t.closing = true
|
|
err := t.conn.Close()
|
|
|
|
return err
|
|
}
|
|
|
|
// Execute transpiles the string Source given in Args into CSS.
|
|
// If Dart Sass resturns a "compile failure", the error returned will be
|
|
// of type SassError.
|
|
func (t *Transpiler) Execute(args Args) (Result, error) {
|
|
var result Result
|
|
|
|
createInboundMessage := func(seq uint32) (*embeddedsass.InboundMessage, error) {
|
|
if err := args.init(seq, t.opts); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
message := &embeddedsass.InboundMessage_CompileRequest_{
|
|
CompileRequest: &embeddedsass.InboundMessage_CompileRequest{
|
|
Importers: args.sassImporters,
|
|
Style: args.sassOutputStyle,
|
|
Input: &embeddedsass.InboundMessage_CompileRequest_String_{
|
|
String_: &embeddedsass.InboundMessage_CompileRequest_StringInput{
|
|
Syntax: args.sassSourceSyntax,
|
|
Source: args.Source,
|
|
Url: args.URL,
|
|
},
|
|
},
|
|
SourceMap: args.EnableSourceMap,
|
|
},
|
|
}
|
|
|
|
return &embeddedsass.InboundMessage{
|
|
Message: message,
|
|
}, nil
|
|
}
|
|
|
|
call, err := t.newCall(createInboundMessage, args)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
|
|
select {
|
|
case call = <-call.Done:
|
|
case <-time.After(t.opts.Timeout):
|
|
return result, errors.New("timeout waiting for Dart Sass to respond; if you're running with Embedded Sass protocol < beta6, you need to upgrade")
|
|
}
|
|
|
|
if call.Error != nil {
|
|
return result, call.Error
|
|
}
|
|
|
|
response := call.Response
|
|
csp := response.Message.(*embeddedsass.OutboundMessage_CompileResponse_)
|
|
|
|
switch resp := csp.CompileResponse.Result.(type) {
|
|
case *embeddedsass.OutboundMessage_CompileResponse_Success:
|
|
result.CSS = resp.Success.Css
|
|
result.SourceMap = resp.Success.SourceMap
|
|
case *embeddedsass.OutboundMessage_CompileResponse_Failure:
|
|
asJson, err := json.Marshal(resp.Failure)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
var sassErr SassError
|
|
err = json.Unmarshal(asJson, &sassErr)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
return result, sassErr
|
|
default:
|
|
return result, fmt.Errorf("unsupported response type: %T", resp)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func (t *Transpiler) getCall(id uint32) *call {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
call, found := t.pending[id]
|
|
if !found {
|
|
panic(fmt.Sprintf("call with ID %d not found", id))
|
|
}
|
|
return call
|
|
}
|
|
|
|
func (t *Transpiler) input() {
|
|
var err error
|
|
|
|
for err == nil {
|
|
// The header is the length in bytes of the remaining message.
|
|
var l uint64
|
|
l, err = binary.ReadUvarint(t.conn)
|
|
if err != nil {
|
|
break
|
|
}
|
|
|
|
plen := int(l)
|
|
if len(t.msgBuf) < plen {
|
|
t.msgBuf = make([]byte, plen)
|
|
}
|
|
|
|
buf := t.msgBuf[:plen]
|
|
|
|
_, err = io.ReadFull(t.conn, buf)
|
|
if err != nil {
|
|
break
|
|
}
|
|
|
|
var msg embeddedsass.OutboundMessage
|
|
|
|
if err = proto.Unmarshal(buf, &msg); err != nil {
|
|
break
|
|
}
|
|
|
|
switch c := msg.Message.(type) {
|
|
case *embeddedsass.OutboundMessage_CompileResponse_:
|
|
id := c.CompileResponse.Id
|
|
// Attach it to the correct pending call.
|
|
t.mu.Lock()
|
|
call := t.pending[id]
|
|
delete(t.pending, id)
|
|
t.mu.Unlock()
|
|
if call == nil {
|
|
err = fmt.Errorf("call with ID %d not found", id)
|
|
break
|
|
}
|
|
call.Response = &msg
|
|
call.done()
|
|
case *embeddedsass.OutboundMessage_CanonicalizeRequest_:
|
|
call := t.getCall(c.CanonicalizeRequest.CompilationId)
|
|
resolved, resolveErr := call.importResolver.CanonicalizeURL(c.CanonicalizeRequest.GetUrl())
|
|
|
|
var response *embeddedsass.InboundMessage_CanonicalizeResponse
|
|
if resolveErr != nil {
|
|
response = &embeddedsass.InboundMessage_CanonicalizeResponse{
|
|
Id: c.CanonicalizeRequest.GetId(),
|
|
Result: &embeddedsass.InboundMessage_CanonicalizeResponse_Error{
|
|
Error: resolveErr.Error(),
|
|
},
|
|
}
|
|
} else {
|
|
var url *embeddedsass.InboundMessage_CanonicalizeResponse_Url
|
|
if resolved != "" {
|
|
url = &embeddedsass.InboundMessage_CanonicalizeResponse_Url{
|
|
Url: resolved,
|
|
}
|
|
}
|
|
response = &embeddedsass.InboundMessage_CanonicalizeResponse{
|
|
Id: c.CanonicalizeRequest.GetId(),
|
|
Result: url,
|
|
}
|
|
}
|
|
|
|
err = t.sendInboundMessage(
|
|
&embeddedsass.InboundMessage{
|
|
Message: &embeddedsass.InboundMessage_CanonicalizeResponse_{
|
|
CanonicalizeResponse: response,
|
|
},
|
|
},
|
|
)
|
|
case *embeddedsass.OutboundMessage_ImportRequest_:
|
|
call := t.getCall(c.ImportRequest.CompilationId)
|
|
url := c.ImportRequest.GetUrl()
|
|
contents, loadErr := call.importResolver.Load(url)
|
|
|
|
var response *embeddedsass.InboundMessage_ImportResponse
|
|
var sourceMapURL string
|
|
|
|
// Dart Sass expect a browser-accessible URL or an empty string.
|
|
// If no URL is supplied, a `data:` URL wil be generated
|
|
// automatically from `contents`
|
|
if hasScheme(url) {
|
|
sourceMapURL = url
|
|
}
|
|
|
|
if loadErr != nil {
|
|
response = &embeddedsass.InboundMessage_ImportResponse{
|
|
Id: c.ImportRequest.GetId(),
|
|
Result: &embeddedsass.InboundMessage_ImportResponse_Error{
|
|
Error: loadErr.Error(),
|
|
},
|
|
}
|
|
} else {
|
|
response = &embeddedsass.InboundMessage_ImportResponse{
|
|
Id: c.ImportRequest.GetId(),
|
|
Result: &embeddedsass.InboundMessage_ImportResponse_Success{
|
|
Success: &embeddedsass.InboundMessage_ImportResponse_ImportSuccess{
|
|
Contents: contents,
|
|
SourceMapUrl: sourceMapURL,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
err = t.sendInboundMessage(
|
|
&embeddedsass.InboundMessage{
|
|
Message: &embeddedsass.InboundMessage_ImportResponse_{
|
|
ImportResponse: response,
|
|
},
|
|
},
|
|
)
|
|
case *embeddedsass.OutboundMessage_LogEvent_:
|
|
// Drop these for now.
|
|
case *embeddedsass.OutboundMessage_Error:
|
|
err = fmt.Errorf("SASS error: %s", c.Error.GetMessage())
|
|
default:
|
|
err = fmt.Errorf("unsupported response message type. %T", msg.Message)
|
|
}
|
|
|
|
}
|
|
|
|
// Terminate pending calls.
|
|
t.sendMu.Lock()
|
|
defer t.sendMu.Unlock()
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
|
|
t.shutdown = true
|
|
isEOF := err == io.EOF || strings.Contains(err.Error(), "already closed")
|
|
if isEOF {
|
|
if t.closing {
|
|
err = ErrShutdown
|
|
} else {
|
|
err = io.ErrUnexpectedEOF
|
|
}
|
|
}
|
|
|
|
for _, call := range t.pending {
|
|
call.Error = err
|
|
call.done()
|
|
}
|
|
}
|
|
|
|
func (t *Transpiler) newCall(createInbound func(seq uint32) (*embeddedsass.InboundMessage, error), args Args) (*call, error) {
|
|
t.mu.Lock()
|
|
id := t.seq
|
|
req, err := createInbound(id)
|
|
if err != nil {
|
|
t.mu.Unlock()
|
|
return nil, err
|
|
}
|
|
|
|
call := &call{
|
|
Request: req,
|
|
Done: make(chan *call, 1),
|
|
importResolver: args.ImportResolver,
|
|
}
|
|
|
|
if t.shutdown || t.closing {
|
|
t.mu.Unlock()
|
|
call.Error = ErrShutdown
|
|
call.done()
|
|
return call, nil
|
|
}
|
|
|
|
t.pending[id] = call
|
|
t.seq++
|
|
|
|
t.mu.Unlock()
|
|
|
|
switch c := call.Request.Message.(type) {
|
|
case *embeddedsass.InboundMessage_CompileRequest_:
|
|
c.CompileRequest.Id = id
|
|
default:
|
|
return nil, fmt.Errorf("unsupported request message type. %T", call.Request.Message)
|
|
}
|
|
|
|
return call, t.sendInboundMessage(call.Request)
|
|
}
|
|
|
|
func (t *Transpiler) sendInboundMessage(message *embeddedsass.InboundMessage) error {
|
|
t.sendMu.Lock()
|
|
defer t.sendMu.Unlock()
|
|
t.mu.Lock()
|
|
if t.closing || t.shutdown {
|
|
t.mu.Unlock()
|
|
return ErrShutdown
|
|
}
|
|
t.mu.Unlock()
|
|
|
|
out, err := proto.Marshal(message)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal request: %s", err)
|
|
}
|
|
|
|
// Every message must begin with a varint indicating the length in bytes of
|
|
// the remaining message.
|
|
reqLen := uint64(len(out))
|
|
|
|
n := binary.PutUvarint(t.lenBuf, reqLen)
|
|
_, err = t.conn.Write(t.lenBuf[:n])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
n, err = t.conn.Write(out)
|
|
if n != len(out) {
|
|
return errors.New("failed to write payload")
|
|
}
|
|
return err
|
|
}
|
|
|
|
type call struct {
|
|
Request *embeddedsass.InboundMessage
|
|
Response *embeddedsass.OutboundMessage
|
|
importResolver ImportResolver
|
|
|
|
Error error
|
|
Done chan *call
|
|
}
|
|
|
|
func (call *call) done() {
|
|
select {
|
|
case call.Done <- call:
|
|
default:
|
|
}
|
|
}
|
|
|
|
func hasScheme(s string) bool {
|
|
u, err := url.ParseRequestURI(s)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return u.Scheme != ""
|
|
}
|