Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement sass --embedded in pure JS mode #2413

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 23 additions & 13 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -128,24 +128,40 @@ jobs:
working-directory: sass-spec

sass_spec_js_embedded:
name: 'JS API Tests | Embedded | Node ${{ matrix.node-version }} | ${{ matrix.os }}'
name: "JS API Tests | Embedded ${{ matrix.js && 'Pure JS' || 'Dart' }} | Node ${{ matrix.node-version }} | ${{ matrix.os }}"
runs-on: ${{ matrix.os }}
if: "github.event_name != 'pull_request' || !contains(github.event.pull_request.body, 'skip sass-embedded')"

strategy:
fail-fast: false
matrix:
js: [true, false]
os: [ubuntu-latest, windows-latest, macos-latest]
node-version: ['lts/*']
include:
# Test older LTS versions
- os: ubuntu-latest
- js: true
os: ubuntu-latest
dart_channel: stable
node-version: lts/-1
- os: ubuntu-latest
- js: true
os: ubuntu-latest
dart_channel: stable
node-version: lts/-2
- os: ubuntu-latest
- js: true
os: ubuntu-latest
dart_channel: stable
node-version: lts/-3
- js: false
os: ubuntu-latest
dart_channel: stable
node-version: lts/-1
- js: false
os: ubuntu-latest
dart_channel: stable
node-version: lts/-2
- js: false
os: ubuntu-latest
dart_channel: stable
node-version: lts/-3

Expand All @@ -168,19 +184,13 @@ jobs:
- name: Initialize embedded host
run: |
npm install
npm run init -- --compiler-path=.. --language-path=../build/language
npm run init -- --compiler-path=.. --language-path=../build/language ${{ matrix.js && '--compiler-js' || '' }}
npm run compile
mv {`pwd`/,dist/}lib/src/vendor/dart-sass
working-directory: embedded-host-node

- name: Version info
run: |
path=embedded-host-node/dist/lib/src/vendor/dart-sass/sass
if [[ -f "$path.cmd" ]]; then "./$path.cmd" --version
elif [[ -f "$path.bat" ]]; then "./$path.bat" --version
elif [[ -f "$path.exe" ]]; then "./$path.exe" --version
else "./$path" --version
fi
run: node dist/bin/sass.js --version
working-directory: embedded-host-node

- name: Run tests
run: npm run js-api-spec -- --sassPackage ../embedded-host-node --sassSassRepo ../build/language
Expand Down
4 changes: 1 addition & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -434,9 +434,7 @@ an API for users to invoke Sass and define custom functions and importers.
* `sass --embedded --version` prints `versionResponse` with `id = 0` in JSON and
exits.

The `--embedded` command-line flag is not available when you install Dart Sass
as an [npm package]. No other command-line flags are supported with
`--embedded`.
No other command-line flags are supported with `--embedded`.

[npm package]: #from-npm

Expand Down
5 changes: 1 addition & 4 deletions bin/sass.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,7 @@ import 'package:sass/src/importer/filesystem.dart';
import 'package:sass/src/io.dart';
import 'package:sass/src/stylesheet_graph.dart';
import 'package:sass/src/utils.dart';
import 'package:sass/src/embedded/executable.dart'
// Never load the embedded protocol when compiling to JS.
if (dart.library.js) 'package:sass/src/embedded/unavailable.dart'
as embedded;
import 'package:sass/src/embedded/executable.dart' as embedded;

Future<void> main(List<String> args) async {
if (args case ['--embedded', ...var rest]) {
Expand Down
29 changes: 15 additions & 14 deletions lib/src/embedded/compilation_dispatcher.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,17 @@
// https://opensource.org/licenses/MIT.

import 'dart:convert';
import 'dart:io';
import 'dart:isolate';
import 'dart:io' if (dart.library.js) 'js/io.dart';
import 'dart:isolate' if (dart.library.js) 'js/isolate.dart';
import 'dart:typed_data';

import 'package:native_synchronization/mailbox.dart';
import 'package:path/path.dart' as p;
import 'package:protobuf/protobuf.dart';
import 'package:pub_semver/pub_semver.dart';
import 'package:sass/sass.dart' as sass;
import 'package:sass/src/importer/node_package.dart' as npi;

import '../io.dart' show FileSystemException;
import '../logger.dart';
import '../value/function.dart';
import '../value/mixin.dart';
Expand All @@ -23,6 +23,7 @@ import 'host_callable.dart';
import 'importer/file.dart';
import 'importer/host.dart';
import 'logger.dart';
import 'sync_receive_port.dart';
import 'util/proto_extensions.dart';
import 'utils.dart';

Expand All @@ -35,8 +36,8 @@ final _outboundRequestId = 0;
/// A class that dispatches messages to and from the host for a single
/// compilation.
final class CompilationDispatcher {
/// The mailbox for receiving messages from the host.
final Mailbox _mailbox;
/// The synchronous receive port for receiving messages from the host.
final SyncReceivePort _receivePort;

/// The send port for sending messages to the host.
final SendPort _sendPort;
Expand All @@ -52,8 +53,8 @@ final class CompilationDispatcher {
late Uint8List _compilationIdVarint;

/// Creates a [CompilationDispatcher] that receives encoded protocol buffers
/// through [_mailbox] and sends them through [_sendPort].
CompilationDispatcher(this._mailbox, this._sendPort);
/// through [_receivePort] and sends them through [_sendPort].
CompilationDispatcher(this._receivePort, this._sendPort);

/// Listens for incoming `CompileRequests` and runs their compilations.
void listen() {
Expand Down Expand Up @@ -408,16 +409,16 @@ final class CompilationDispatcher {
message.writeToCodedBufferWriter(protobufWriter);

// Add one additional byte to the beginning to indicate whether or not the
// compilation has finished (1) or encountered a fatal error (2), so the
// [IsolateDispatcher] knows whether to treat this isolate as inactive or
// close out entirely.
// compilation has finished (1) or encountered a fatal error (exitCode), so
// the [IsolateDispatcher] knows whether to treat this isolate as inactive
// or close out entirely.
var packet = Uint8List(
1 + _compilationIdVarint.length + protobufWriter.lengthInBytes,
);
packet[0] = switch (message.whichMessage()) {
OutboundMessage_Message.compileResponse => 1,
OutboundMessage_Message.error => 2,
_ => 0,
OutboundMessage_Message.error => exitCode,
Copy link
Contributor

Choose a reason for hiding this comment

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

There's a risk here that exitCode is set to 0 or 1 and causes this to silently do the wrong thing. We should probably check for that, even if we're confident it can't occur with the code as-is.

_ => 0
};
packet.setAll(1, _compilationIdVarint);
protobufWriter.writeTo(packet, 1 + _compilationIdVarint.length);
Expand All @@ -427,9 +428,9 @@ final class CompilationDispatcher {
/// Receive a packet from the host.
Uint8List _receive() {
try {
return _mailbox.take();
return _receivePort.receive();
} on StateError catch (_) {
// The [_mailbox] has been closed, exit the current isolate immediately
// The [SyncReceivePort] has been closed, exit the current isolate immediately
// to avoid bubble the error up as [SassException] during [_sendRequest].
Isolate.exit();
}
Expand Down
41 changes: 2 additions & 39 deletions lib/src/embedded/executable.dart
Original file line number Diff line number Diff line change
@@ -1,42 +1,5 @@
// Copyright 2019 Google Inc. Use of this source code is governed by an
// Copyright 2025 Google Inc. Use of this source code is governed by an
// MIT-style license that can be found in the LICENSE file or at
// https://opensource.org/licenses/MIT.

import 'dart:io';
import 'dart:convert';

import 'package:stream_channel/stream_channel.dart';

import 'isolate_dispatcher.dart';
import 'util/length_delimited_transformer.dart';

void main(List<String> args) {
switch (args) {
case ["--version", ...]:
var response = IsolateDispatcher.versionResponse();
response.id = 0;
stdout.writeln(
JsonEncoder.withIndent(" ").convert(response.toProto3Json()),
);
return;

case [_, ...]:
stderr.writeln(
"sass --embedded is not intended to be executed with additional "
"arguments.\n"
"See https://github.com/sass/dart-sass#embedded-dart-sass for "
"details.",
);
// USAGE error from https://bit.ly/2poTt90
exitCode = 64;
return;
}

IsolateDispatcher(
StreamChannel.withGuarantees(
stdin,
stdout,
allowSinkErrors: false,
).transform(lengthDelimited),
).listen();
}
export 'vm/executable.dart' if (dart.library.js) 'js/executable.dart';
10 changes: 10 additions & 0 deletions lib/src/embedded/js/concurrency.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Copyright 2025 Google Inc. Use of this source code is governed by an
// MIT-style license that can be found in the LICENSE file or at
// https://opensource.org/licenses/MIT.

import 'dart:js_interop';

@JS('os.cpus')
external JSArray _cpus();

int get concurrencyLimit => _cpus().length;
27 changes: 27 additions & 0 deletions lib/src/embedded/js/executable.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Copyright 2025 Google Inc. Use of this source code is governed by an
// MIT-style license that can be found in the LICENSE file or at
// https://opensource.org/licenses/MIT.

import 'package:stream_channel/stream_channel.dart';

import '../options.dart';
import '../util/length_delimited_transformer.dart';
import '../worker_dispatcher.dart';
import '../worker_entrypoint.dart';
import 'io.dart';
import 'sync_receive_port.dart';
import 'worker_threads.dart';

void main(List<String> args) {
if (parseOptions(args)) {
if (isMainThread) {
WorkerDispatcher(StreamChannel.withGuarantees(stdin, stdout,
allowSinkErrors: false)
.transform(lengthDelimited))
.listen();
} else {
var port = workerData! as MessagePort;
workerEntryPoint(JSSyncReceivePort(port), JSSendPort(port));
}
}
}
66 changes: 66 additions & 0 deletions lib/src/embedded/js/io.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Copyright 2025 Google Inc. Use of this source code is governed by an
// MIT-style license that can be found in the LICENSE file or at
// https://opensource.org/licenses/MIT.

import 'dart:async';
import 'dart:js_interop';
import 'dart:typed_data';

@JS('process.exitCode')
external int? get _exitCode;
int get exitCode => _exitCode ?? 0;

@JS('process.exitCode')
external set exitCode(int code);

@JS('process.exit')
external void exit([int code]);

@JS()
extension type _ReadStream(JSObject _) implements JSObject {
external void destroy();
external void on(String type, JSFunction listener);
}

@JS('process.stdin')
external _ReadStream get _stdin;

@JS()
extension type _WriteStream(JSObject _) implements JSObject {
external void write(JSUint8Array chunk);
}

@JS('process.stdout')
external _WriteStream get _stdout;

Stream<List<int>> get stdin {
var controller = StreamController<Uint8List>(
onCancel: () {
_stdin.destroy();
},
sync: true);
_stdin.on(
'data',
(JSUint8Array chunk) {
controller.sink.add(chunk.toDart);
}.toJS);
_stdin.on(
'end',
() {
controller.sink.close();
}.toJS);
_stdin.on(
'error',
(JSObject e) {
controller.sink.addError(e);
}.toJS);
return controller.stream;
}

StreamSink<List<int>> get stdout {
var controller = StreamController<Uint8List>(sync: true);
controller.stream.listen((buffer) {
_stdout.write(buffer.toJS);
});
return controller.sink;
}
17 changes: 17 additions & 0 deletions lib/src/embedded/js/isolate.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Copyright 2025 Google LLC. Use of this source code is governed by an
// MIT-style license that can be found in the LICENSE file or at
// https://opensource.org/licenses/MIT.

import 'dart:isolate' show SendPort;
export 'dart:isolate' show SendPort;

import 'io.dart' as io;

abstract class Isolate {
static Never exit([SendPort? finalMessagePort, Object? message]) {
if (message != null) {
finalMessagePort?.send(message);
}
io.exit(io.exitCode) as Never;
}
}
13 changes: 13 additions & 0 deletions lib/src/embedded/js/js.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Copyright 2025 Google Inc. Use of this source code is governed by an
// MIT-style license that can be found in the LICENSE file or at
// https://opensource.org/licenses/MIT.

import 'dart:js_interop';

extension JSTypedArrayExtension on JSTypedArray {
external JSArrayBuffer get buffer;
}

extension JSArrayExtension<T extends JSAny?> on JSArray<T> {
external JSArray<T> slice([int start, int end]);
}
Loading