diff --git a/lib/src/node.dart b/lib/src/node.dart index dd202b993..7c3e4bbfc 100644 --- a/lib/src/node.dart +++ b/lib/src/node.dart @@ -27,6 +27,7 @@ import 'node/render_result.dart'; import 'node/types.dart'; import 'node/value.dart'; import 'node/utils.dart'; +import 'node/worker_threads.dart'; import 'parse/scss.dart'; import 'syntax.dart'; import 'value.dart'; @@ -63,26 +64,15 @@ void main() { /// [render]: https://github.com/sass/node-sass#options void _render( RenderOptions options, void callback(JSError error, RenderResult result)) { - if (options.fiber != null) { - options.fiber.call(allowInterop(() { - try { - callback(null, _renderSync(options)); - } catch (error) { - callback(error as JSError, null); - } - return null; - })).run(); - } else { - _renderAsync(options).then((result) { - callback(null, result); - }, onError: (Object error, StackTrace stackTrace) { - if (error is SassException) { - callback(_wrapException(error), null); - } else { - callback(_newRenderError(error.toString(), status: 3), null); - } - }); - } + _renderAsync(options).then((result) { + callback(null, result); + }, onError: (Object error, StackTrace stackTrace) { + if (error is SassException) { + callback(_wrapException(error), null); + } else { + callback(_newRenderError(error.toString(), status: 3), null); + } + }); } /// Converts Sass to CSS asynchronously. @@ -90,29 +80,39 @@ Future _renderAsync(RenderOptions options) async { var start = DateTime.now(); var file = options.file == null ? null : p.absolute(options.file); CompileResult result; - if (options.data != null) { - result = await compileStringAsync(options.data, - nodeImporter: _parseImporter(options, start), - functions: _parseFunctions(options, asynch: true), - syntax: isTruthy(options.indentedSyntax) ? Syntax.sass : null, - style: _parseOutputStyle(options.outputStyle), - useSpaces: options.indentType != 'tab', - indentWidth: _parseIndentWidth(options.indentWidth), - lineFeed: _parseLineFeed(options.linefeed), - url: options.file == null ? 'stdin' : p.toUri(file).toString(), - sourceMap: _enableSourceMaps(options)); - } else if (options.file != null) { - result = await compileAsync(file, - nodeImporter: _parseImporter(options, start), - functions: _parseFunctions(options, asynch: true), - syntax: isTruthy(options.indentedSyntax) ? Syntax.sass : null, - style: _parseOutputStyle(options.outputStyle), - useSpaces: options.indentType != 'tab', - indentWidth: _parseIndentWidth(options.indentWidth), - lineFeed: _parseLineFeed(options.linefeed), - sourceMap: _enableSourceMaps(options)); - } else { - throw ArgumentError("Either options.data or options.file must be set."); + if (isMainThread == true) { + print(p.current); + final worker = Worker(p.current, WorkerOptions(workerData: {options})); + worker.on('message', (CompileResult msg) => result = msg); + worker.on('error', (JSError error) { + jsThrow(_wrapException(error)); + }); + } else if (isMainThread == false) { + if (options.data != null) { + result = await compileStringAsync(options.data, + nodeImporter: _parseImporter(options, start), + functions: _parseFunctions(options, asynch: true), + syntax: isTruthy(options.indentedSyntax) ? Syntax.sass : null, + style: _parseOutputStyle(options.outputStyle), + useSpaces: options.indentType != 'tab', + indentWidth: _parseIndentWidth(options.indentWidth), + lineFeed: _parseLineFeed(options.linefeed), + url: options.file == null ? 'stdin' : p.toUri(file).toString(), + sourceMap: _enableSourceMaps(options)); + } else if (options.file != null) { + result = await compileAsync(file, + nodeImporter: _parseImporter(options, start), + functions: _parseFunctions(options, asynch: true), + syntax: isTruthy(options.indentedSyntax) ? Syntax.sass : null, + style: _parseOutputStyle(options.outputStyle), + useSpaces: options.indentType != 'tab', + indentWidth: _parseIndentWidth(options.indentWidth), + lineFeed: _parseLineFeed(options.linefeed), + sourceMap: _enableSourceMaps(options)); + } else { + throw ArgumentError("Either options.data or options.file must be set."); + } + parentPort.postMessage(result, PortOptions()); } return _newRenderResult(options, result, start); diff --git a/lib/src/node/worker_threads.dart b/lib/src/node/worker_threads.dart new file mode 100644 index 000000000..36bb0d8f1 --- /dev/null +++ b/lib/src/node/worker_threads.dart @@ -0,0 +1,51 @@ +// Copyright 2020 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. +@JS("worker_threads") +library worker_threads; + +import 'package:js/js.dart'; + +@JS("worker_threads") +external WorkerThreads get worker_threads; + +bool isMainThread = worker_threads?.isMainThread; +ParentPort parentPort = worker_threads?.parentPort; + +@JS() +abstract class WorkerThreads { + @JS('Worker') + external Worker get worker; + external bool get workerData; + external bool get isMainThread; + external ParentPort get parentPort; + external const factory WorkerThreads(); +} + +@JS() +@anonymous +class WorkerOptions { + external factory WorkerOptions( + {Object env, bool eval, List execArgv, Object workerData}); +} + +@JS() +class Worker { + external void on(String message, Function callback); + + external const factory Worker(String fileName, WorkerOptions options); +} + +@JS("parentPort") +@anonymous +class PortOptions { + external List get transferList; + external factory PortOptions({List transferList = const []}); +} + +@JS("parentPort") +@anonymous +abstract class ParentPort { + external factory ParentPort({Function postMessage}); + external void postMessage(Object message, PortOptions options); +} diff --git a/package/package.json b/package/package.json index 379ba662d..3debe3e1e 100644 --- a/package/package.json +++ b/package/package.json @@ -14,7 +14,7 @@ "url": "https://github.com/nex3" }, "engines": { - "node": ">=8.9.0" + "node": ">=11.7.0" }, "dependencies": { "chokidar": ">=2.0.0 <4.0.0" diff --git a/test/node_api/function_test.dart b/test/node_api/function_test.dart index e42ef79a9..d51f383e0 100644 --- a/test/node_api/function_test.dart +++ b/test/node_api/function_test.dart @@ -287,95 +287,6 @@ void main() { data: "", functions: jsify({"foo(": allowInterop(neverCalled)}))); expect(error.toString(), contains('Invalid signature')); }); - - group("with fibers", () { - setUpAll(() { - try { - fiber; - } catch (_) { - throw "Can't load fibers package.\n" - "Run pub run grinder before-test."; - } - }); - - test("runs a synchronous function", () { - expect( - render(RenderOptions( - data: "a {b: foo()}", - functions: jsify({ - "foo": allowInterop( - (void _) => callConstructor(sass.types.Number, [1])) - }), - fiber: fiber)), - completion(equalsIgnoringWhitespace("a { b: 1; }"))); - }); - - test("runs an asynchronous function", () { - expect( - render(RenderOptions( - data: "a {b: foo()}", - functions: jsify({ - "foo": allowInterop((void done(Object result)) { - Timer(Duration.zero, () { - done(callConstructor(sass.types.Number, [1])); - }); - }) - }), - fiber: fiber)), - completion(equalsIgnoringWhitespace("a { b: 1; }"))); - }); - - test("reports a synchronous error", () async { - var error = await renderError(RenderOptions( - data: "a {b: foo()}", - functions: - jsify({"foo": allowInterop((void _) => throw "aw beans")}), - fiber: fiber)); - expect(error.toString(), contains('aw beans')); - }); - - test("reports an asynchronous error", () async { - var error = await renderError(RenderOptions( - data: "a {b: foo()}", - functions: jsify({ - "foo": allowInterop((void done(Object result)) { - Timer(Duration.zero, () { - done(JSError("aw beans")); - }); - }) - }), - fiber: fiber)); - expect(error.toString(), contains('aw beans')); - }); - - test("reports a null return", () async { - var error = await renderError(RenderOptions( - data: "a {b: foo()}", - functions: jsify({ - "foo": allowInterop((void done(Object result)) { - Timer(Duration.zero, () { - done(null); - }); - }) - }), - fiber: fiber)); - expect(error.toString(), contains('must be a Sass value type')); - }); - - test("reports a call to done without arguments", () async { - var error = await renderError(RenderOptions( - data: "a {b: foo()}", - functions: jsify({ - "foo": allowInterop((void done()) { - Timer(Duration.zero, () { - done(); - }); - }) - }), - fiber: fiber)); - expect(error.toString(), contains('must be a Sass value type')); - }); - }); }); // Node Sass currently doesn't provide any representation of first-class diff --git a/test/node_api/importer_test.dart b/test/node_api/importer_test.dart index 870ff52fd..565a20b66 100644 --- a/test/node_api/importer_test.dart +++ b/test/node_api/importer_test.dart @@ -668,6 +668,8 @@ void main() { " stdin 1:9 root stylesheet"))); }); + //TODO: Remove these tests + /******* Deprecated fibers tests group("with fibers", () { setUpAll(() { try { @@ -749,5 +751,6 @@ void main() { " stdin 1:9 root stylesheet"))); }); }); + */ }); } diff --git a/tool/grind.dart b/tool/grind.dart index 5eb558b6f..de7aa2350 100644 --- a/tool/grind.dart +++ b/tool/grind.dart @@ -26,7 +26,12 @@ void main(List args) { pkg.chocolateyNuspec = _nuspec; pkg.homebrewRepo = "sass/homebrew-sass"; pkg.homebrewFormula = "sass.rb"; - pkg.jsRequires = {"fs": "fs", "chokidar": "chokidar", "readline": "readline"}; + pkg.jsRequires = { + "fs": "fs", + "chokidar": "chokidar", + "readline": "readline", + "workerThreads": "worker_threads" + }; pkg.jsModuleMainLibrary = "lib/src/node.dart"; pkg.npmPackageJson = json.decode(File("package/package.json").readAsStringSync()) diff --git a/tool/grind/npm.dart b/tool/grind/npm.dart new file mode 100644 index 000000000..951ea99ad --- /dev/null +++ b/tool/grind/npm.dart @@ -0,0 +1,169 @@ +// Copyright 2018 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:convert'; +import 'dart:io'; + +import 'package:charcode/charcode.dart'; +import 'package:grinder/grinder.dart'; +import 'package:meta/meta.dart'; +import 'package:node_preamble/preamble.dart' as preamble; +import 'package:path/path.dart' as p; +import 'package:source_span/source_span.dart'; + +import 'utils.dart'; + +@Task('Compile to JS in dev mode.') +void js() => _js(release: false); + +@Task('Compile to JS in release mode.') +void jsRelease() => _js(release: true); + +/// Compiles Sass to JS. +/// +/// If [release] is `true`, this compiles minified with +/// --trust-type-annotations. Otherwise, it compiles unminified with pessimistic +/// type checks. +void _js({@required bool release}) { + ensureBuild(); + var destination = File('build/sass.dart.js'); + + Dart2js.compile(File('bin/sass.dart'), outFile: destination, extraArgs: [ + '--categories=Server', + '-Dnode=true', + '-Dversion=$version', + '-Ddart-version=$dartVersion', + // We use O4 because: + // + // * We don't care about the string representation of types. + // * We expect our test coverage to ensure that nothing throws subtypes of + // Error. + // * We thoroughly test edge cases in user input. + // + // We don't minify because download size isn't especially important + // server-side and it's nice to get readable stack traces from bug reports. + if (release) ...["-O4", "--no-minify", "--fast-startup"] + ]); + var text = destination + .readAsStringSync() + // Some dependencies dynamically invoke `require()`, which makes Webpack + // complain. We replace those with direct references to the modules, which + // we load explicitly after the preamble. + .replaceAllMapped(RegExp(r'self\.require\("([a-zA-Z0-9_-]+)"\)'), + (match) => "self.${match[1]}"); + + if (release) { + // We don't ship the source map, so remove the source map comment. + text = + text.replaceFirst(RegExp(r"\n*//# sourceMappingURL=[^\n]+\n*$"), "\n"); + } + + // Reassigning require() makes Webpack complain. + var preambleText = + preamble.getPreamble().replaceFirst("self.require = require;\n", ""); + + destination.writeAsStringSync(""" +$preambleText +self.fs = require("fs"); +self.chokidar = require("chokidar"); +self.readline = require("readline"); +self.workerThreads = require("worker_threads"); +$text"""); +} + +@Task('Build a pure-JS dev-mode npm package.') +@Depends(js) +void npmPackage() => _npm(release: false); + +@Task('Build a pure-JS release-mode npm package.') +@Depends(jsRelease) +void npmReleasePackage() => _npm(release: true); + +/// Builds a pure-JS npm package. +/// +/// If [release] is `true`, this compiles minified with +/// --trust-type-annotations. Otherwise, it compiles unminified with pessimistic +/// type checks. +void _npm({@required bool release}) { + var json = { + ...(jsonDecode(File('package/package.json').readAsStringSync()) + as Map), + "version": version + }; + + _writeNpmPackage('build/npm', json); + if (release) { + _writeNpmPackage('build/npm-old', {...json, "name": "dart-sass"}); + } +} + +/// Writes a Dart Sass NPM package to the directory at [destination]. +/// +/// The [json] will be used as the package's package.json. +void _writeNpmPackage(String destination, Map json) { + var dir = Directory(destination); + if (dir.existsSync()) dir.deleteSync(recursive: true); + dir.createSync(recursive: true); + + log("copying package/package.json to $destination"); + File(p.join(destination, 'package.json')).writeAsStringSync(jsonEncode(json)); + + copy(File(p.join('package', 'sass.js')), dir); + copy(File(p.join('build', 'sass.dart.js')), dir); + + log("copying package/README.npm.md to $destination"); + File(p.join(destination, 'README.md')) + .writeAsStringSync(_readAndResolveMarkdown('package/README.npm.md')); +} + +final _readAndResolveRegExp = RegExp( + r"^$", + multiLine: true); + +/// Reads a Markdown file from [path] and resolves include directives. +/// +/// Include directives have the syntax `""`, +/// which must appear on its own line. PATH is a relative file: URL to another +/// Markdown file, and HEADER is the name of a header in that file whose +/// contents should be included as-is. +String _readAndResolveMarkdown(String path) => File(path) + .readAsStringSync() + .replaceAllMapped(_readAndResolveRegExp, (match) { + String included; + try { + included = File(p.join(p.dirname(path), p.fromUri(match[1]))) + .readAsStringSync(); + } catch (error) { + _matchError(match, error.toString(), url: p.toUri(path)); + } + + Match headerMatch; + try { + headerMatch = "# ${match[2]}\n".allMatches(included).first; + } on StateError { + _matchError(match, "Could not find header.", url: p.toUri(path)); + } + + var headerLevel = 0; + var index = headerMatch.start; + while (index >= 0 && included.codeUnitAt(index) == $hash) { + headerLevel++; + index--; + } + + // The section goes until the next header of the same level, or the end + // of the document. + var sectionEnd = included.indexOf("#" * headerLevel, headerMatch.end); + if (sectionEnd == -1) sectionEnd = included.length; + + return included.substring(headerMatch.end, sectionEnd).trim(); + }); + +/// Throws a nice [SourceSpanException] associated with [match]. +void _matchError(Match match, String message, {Object url}) { + var file = SourceFile.fromString(match.input, url: url); + throw SourceSpanException(message, file.span(match.start, match.end)); +}