diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc25b123..d75baccf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,6 @@ defaults: env: PROTOC_VERSION: 3.x - DEFAULT_NODE_VERSION: 18.x # If changing this, also change jobs.tests.strategy.matrix.node_version on: push: @@ -19,10 +18,10 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 with: - node-version: ${{ env.DEFAULT_NODE_VERSION }} + node-version: 'lts/*' check-latest: true - name: Check out the language repo @@ -43,12 +42,12 @@ jobs: strategy: matrix: os: [ubuntu, macos, windows] - node-version: [18.x, 16.x] # If changing this, also change env.DEFAULT_NODE_VERSION + node-version: ['lts/*', 'lts/-1', 'lts/-2'] fail-fast: false steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} check-latest: true @@ -73,9 +72,6 @@ jobs: - run: npm run compile - run: node test/after-compile-test.mjs - # The versions should be kept up-to-date with the latest LTS Node releases. - # They next need to be rotated October 2021. See - # https://github.com/nodejs/Release. sass_spec: name: 'JS API Tests | Node ${{ matrix.node_version }} | ${{ matrix.os }}' runs-on: ${{ matrix.os }}-latest @@ -84,17 +80,19 @@ jobs: fail-fast: false matrix: os: [ubuntu, windows, macos] - node_version: [18] + node_version: ['lts/*'] include: # Include LTS versions on Ubuntu - os: ubuntu - node_version: 16 + node_version: lts/-1 + - os: ubuntu + node_version: lts/-2 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - uses: dart-lang/setup-dart@v1 with: {sdk: stable} - - uses: actions/setup-node@v2 + - uses: actions/setup-node@v3 with: {node-version: "${{ matrix.node_version }}"} - name: Check out Dart Sass @@ -142,10 +140,10 @@ jobs: needs: [static_analysis, tests, sass_spec] steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 with: - node-version: ${{ env.DEFAULT_NODE_VERSION }} + node-version: 'lts/*' check-latest: true registry-url: 'https://registry.npmjs.org' - run: npm install @@ -159,9 +157,7 @@ jobs: env: NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}' run: | - for pkg in linux-arm linux-arm64 linux-ia32 linux-x64 darwin-arm64 darwin-x64 win32-ia32 win32-x64; do - npx ts-node ./tool/prepare-optional-release.ts --package=$pkg && npm publish ./npm/$pkg - done + find ./npm -mindepth 1 -maxdepth 1 -print0 | xargs -0 -n 1 -- sh -xc 'npx ts-node ./tool/prepare-optional-release.ts --package=$(basename $1) && npm publish $1' -- - run: npm publish env: diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ff3e06f..6397a774 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,126 @@ +## 1.72.0 + +* Support adjacent `/`s without whitespace in between when parsing plain CSS + expressions. + +* Allow the Node.js `pkg:` importer to load Sass stylesheets for `package.json` + `exports` field entries without extensions. + +* When printing suggestions for variables, use underscores in variable names + when the original usage used underscores. + +### JavaScript API + +* Properly resolve `pkg:` imports with the Node.js package importer when + arguments are passed to the JavaScript process. + +## 1.71.1 + +### Command-Line Interface + +* Ship the musl Linux release with the proper Dart executable. + +### JavaScript API + +* Export the `NodePackageImporter` class in ESM mode. + +* Allow `NodePackageImporter` to locate a default directory even when the + entrypoint is an ESM module. + +### Dart API + +* Make passing a null argument to `NodePackageImporter()` a static error rather + than just a runtime error. + +### Embedded Sass + +* In the JS Embedded Host, properly install the musl Linux embedded compiler + when running on musl Linux. + +## 1.71.0 + +For more information about `pkg:` importers, see [the +announcement][pkg-importers] on the Sass blog. + +[pkg-importers]: https://sass-lang.com/blog/announcing-pkg-importers + +### Command-Line Interface + +* Add a `--pkg-importer` flag to enable built-in `pkg:` importers. Currently + this only supports the Node.js package resolution algorithm, via + `--pkg-importer=node`. For example, `@use "pkg:bootstrap"` will load + `node_modules/bootstrap/scss/bootstrap.scss`. + +### JavaScript API + +* Add a `NodePackageImporter` importer that can be passed to the `importers` + option. This loads files using the `pkg:` URL scheme according to the Node.js + package resolution algorithm. For example, `@use "pkg:bootstrap"` will load + `node_modules/bootstrap/scss/bootstrap.scss`. The constructor takes a single + optional argument, which indicates the base directory to use when locating + `node_modules` directories. It defaults to + `path.dirname(require.main.filename)`. + +### Dart API + +* Add a `NodePackageImporter` importer that can be passed to the `importers` + option. This loads files using the `pkg:` URL scheme according to the Node.js + package resolution algorithm. For example, `@use "pkg:bootstrap"` will load + `node_modules/bootstrap/scss/bootstrap.scss`. The constructor takes a single + argument, which indicates the base directory to use when locating + `node_modules` directories. + +## 1.70.0 + +### JavaScript API + +* Add a `sass.initCompiler()` function that returns a `sass.Compiler` object + which supports `compile()` and `compileString()` methods with the same API as + the global Sass object. On the Node.js embedded host, each `sass.Compiler` + object uses a single long-lived subprocess, making compiling multiple + stylesheets much more efficient. + +* Add a `sass.initAsyncCompiler()` function that returns a `sass.AsyncCompiler` + object which supports `compileAsync()` and `compileStringAsync()` methods with + the same API as the global Sass object. On the Node.js embedded host, each + `sass.AsynCompiler` object uses a single long-lived subprocess, making + compiling multiple stylesheets much more efficient. + +### Embedded Sass + +* Support the `CompileRequest.silent` field. This allows compilations with no + logging to avoid unnecessary request/response cycles. + +* The Dart Sass embedded compiler now reports its name as "dart-sass" rather + than "Dart Sass", to match the JS API's `info` field. + +## 1.69.7 + +### Embedded Sass + +* In the JS Embedded Host, properly install the x64 Dart Sass executable on + ARM64 Windows. + +## 1.69.6 + +* Produce better output for numbers with complex units in `meta.inspect()` and + debugging messages. + +* Escape U+007F DELETE when serializing strings. + +* When generating CSS error messages to display in-browser, escape all code + points that aren't in the US-ASCII region. Previously only code points U+0100 + LATIN CAPITAL LETTER A WITH MACRON were escaped. + +* Provide official releases for musl LibC and for Android. + +* Don't crash when running `meta.apply()` in asynchronous mode. + +### JS API + +* Fix a bug where certain exceptions could produce `SourceSpan`s that didn't + follow the documented `SourceSpan` API. + ## 1.69.5 ### JS API diff --git a/lib/index.mjs b/lib/index.mjs index ff94ed4e..c988be20 100644 --- a/lib/index.mjs +++ b/lib/index.mjs @@ -4,13 +4,18 @@ export const compile = sass.compile; export const compileAsync = sass.compileAsync; export const compileString = sass.compileString; export const compileStringAsync = sass.compileStringAsync; +export const NodePackageImporter = sass.NodePackageImporter; +export const AsyncCompiler = sass.AsyncCompiler; +export const Compiler = sass.Compiler; +export const initAsyncCompiler = sass.initAsyncCompiler; +export const initCompiler = sass.initCompiler; export const Logger = sass.Logger; -export const CalculationInterpolation = sass.CalculationInterpolation -export const CalculationOperation = sass.CalculationOperation -export const CalculationOperator = sass.CalculationOperator +export const CalculationInterpolation = sass.CalculationInterpolation; +export const CalculationOperation = sass.CalculationOperation; +export const CalculationOperator = sass.CalculationOperator; export const SassArgumentList = sass.SassArgumentList; export const SassBoolean = sass.SassBoolean; -export const SassCalculation = sass.SassCalculation +export const SassCalculation = sass.SassCalculation; export const SassColor = sass.SassColor; export const SassFunction = sass.SassFunction; export const SassMixin = sass.SassMixin; @@ -39,8 +44,9 @@ function defaultExportDeprecation() { if (printedDefaultExportDeprecation) return; printedDefaultExportDeprecation = true; console.error( - "`import sass from 'sass'` is deprecated.\n" + - "Please use `import * as sass from 'sass'` instead."); + "`import sass from 'sass'` is deprecated.\n" + + "Please use `import * as sass from 'sass'` instead." + ); } export default { @@ -60,6 +66,26 @@ export default { defaultExportDeprecation(); return sass.compileStringAsync; }, + get NodePackageImporter() { + defaultExportDeprecation(); + return sass.NodePackageImporter; + }, + get initAsyncCompiler() { + defaultExportDeprecation(); + return sass.initAsyncCompiler; + }, + get initCompiler() { + defaultExportDeprecation(); + return sass.initCompiler; + }, + get AsyncCompiler() { + defaultExportDeprecation(); + return sass.AsyncCompiler; + }, + get Compiler() { + defaultExportDeprecation(); + return sass.Compiler; + }, get Logger() { defaultExportDeprecation(); return sass.Logger; diff --git a/lib/index.ts b/lib/index.ts index 69005262..da4965d5 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -31,14 +31,15 @@ export { compileString, compileAsync, compileStringAsync, + NodePackageImporter, } from './src/compile'; +export {initAsyncCompiler, AsyncCompiler} from './src/compiler/async'; +export {initCompiler, Compiler} from './src/compiler/sync'; export {render, renderSync} from './src/legacy'; export const info = `sass-embedded\t${pkg.version}`; -export const Logger = { - silent: {warn() {}, debug() {}}, -}; +export {Logger} from './src/logger'; // Legacy JS API diff --git a/lib/src/async-compiler.ts b/lib/src/async-compiler.ts deleted file mode 100644 index 90aa0c88..00000000 --- a/lib/src/async-compiler.ts +++ /dev/null @@ -1,47 +0,0 @@ -// 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. - -import {spawn} from 'child_process'; -import {Observable} from 'rxjs'; -import {takeUntil} from 'rxjs/operators'; - -import {compilerCommand} from './compiler-path'; - -/** - * An asynchronous wrapper for the embedded Sass compiler that exposes its stdio - * streams as Observables. - */ -export class AsyncEmbeddedCompiler { - /** The underlying process that's being wrapped. */ - private readonly process = spawn( - compilerCommand[0], - [...compilerCommand.slice(1), '--embedded'], - {windowsHide: true} - ); - - /** The child process's exit event. */ - readonly exit$ = new Promise(resolve => { - this.process.on('exit', code => resolve(code)); - }); - - /** The buffers emitted by the child process's stdout. */ - readonly stdout$ = new Observable(observer => { - this.process.stdout.on('data', buffer => observer.next(buffer)); - }).pipe(takeUntil(this.exit$)); - - /** The buffers emitted by the child process's stderr. */ - readonly stderr$ = new Observable(observer => { - this.process.stderr.on('data', buffer => observer.next(buffer)); - }).pipe(takeUntil(this.exit$)); - - /** Writes `buffer` to the child process's stdin. */ - writeStdin(buffer: Buffer): void { - this.process.stdin.write(buffer); - } - - /** Kills the child process, cleaning up all associated Observables. */ - close() { - this.process.stdin.end(); - } -} diff --git a/lib/src/compile.ts b/lib/src/compile.ts index 96d3a0a2..0f249945 100644 --- a/lib/src/compile.ts +++ b/lib/src/compile.ts @@ -2,356 +2,57 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. -import * as p from 'path'; -import {Observable} from 'rxjs'; -import * as supportsColor from 'supports-color'; +import {initAsyncCompiler} from './compiler/async'; +import {OptionsWithLegacy, StringOptionsWithLegacy} from './compiler/utils'; +import {initCompiler} from './compiler/sync'; +import {CompileResult} from './vendor/sass'; -import * as proto from './vendor/embedded_sass_pb'; -import * as utils from './utils'; -import {AsyncEmbeddedCompiler} from './async-compiler'; -import {CompileResult, Options, SourceSpan, StringOptions} from './vendor/sass'; -import {Dispatcher, DispatcherHandlers} from './dispatcher'; -import {Exception} from './exception'; -import {FunctionRegistry} from './function-registry'; -import {ImporterRegistry} from './importer-registry'; -import {MessageTransformer} from './message-transformer'; -import {PacketTransformer} from './packet-transformer'; -import {SyncEmbeddedCompiler} from './sync-compiler'; -import {deprotofySourceSpan} from './deprotofy-span'; -import { - removeLegacyImporter, - removeLegacyImporterFromSpan, - legacyImporterProtocol, -} from './legacy/utils'; - -/// Allow the legacy API to pass in an option signaling to the modern API that -/// it's being run in legacy mode. -/// -/// This is not intended for API users to pass in, and may be broken without -/// warning in the future. -type OptionsWithLegacy = Options & { - legacy?: boolean; -}; - -/// Allow the legacy API to pass in an option signaling to the modern API that -/// it's being run in legacy mode. -/// -/// This is not intended for API users to pass in, and may be broken without -/// warning in the future. -type StringOptionsWithLegacy = - StringOptions & {legacy?: boolean}; +export {NodePackageImporter} from './importer-registry'; export function compile( path: string, options?: OptionsWithLegacy<'sync'> ): CompileResult { - const importers = new ImporterRegistry(options); - return compileRequestSync( - newCompilePathRequest(path, importers, options), - importers, - options - ); + const compiler = initCompiler(); + try { + return compiler.compile(path, options); + } finally { + compiler.dispose(); + } } export function compileString( source: string, options?: StringOptionsWithLegacy<'sync'> ): CompileResult { - const importers = new ImporterRegistry(options); - return compileRequestSync( - newCompileStringRequest(source, importers, options), - importers, - options - ); + const compiler = initCompiler(); + try { + return compiler.compileString(source, options); + } finally { + compiler.dispose(); + } } -export function compileAsync( +export async function compileAsync( path: string, options?: OptionsWithLegacy<'async'> ): Promise { - const importers = new ImporterRegistry(options); - return compileRequestAsync( - newCompilePathRequest(path, importers, options), - importers, - options - ); -} - -export function compileStringAsync( - source: string, - options?: StringOptionsWithLegacy<'async'> -): Promise { - const importers = new ImporterRegistry(options); - return compileRequestAsync( - newCompileStringRequest(source, importers, options), - importers, - options - ); -} - -// Creates a request for compiling a file. -function newCompilePathRequest( - path: string, - importers: ImporterRegistry<'sync' | 'async'>, - options?: Options<'sync' | 'async'> -): proto.InboundMessage_CompileRequest { - const request = newCompileRequest(importers, options); - request.input = {case: 'path', value: path}; - return request; -} - -// Creates a request for compiling a string. -function newCompileStringRequest( - source: string, - importers: ImporterRegistry<'sync' | 'async'>, - options?: StringOptions<'sync' | 'async'> -): proto.InboundMessage_CompileRequest { - const input = new proto.InboundMessage_CompileRequest_StringInput({ - source, - syntax: utils.protofySyntax(options?.syntax ?? 'scss'), - }); - - const url = options?.url?.toString(); - if (url && url !== legacyImporterProtocol) { - input.url = url; - } - - if (options && 'importer' in options && options.importer) { - input.importer = importers.register(options.importer); - } else if (url === legacyImporterProtocol) { - input.importer = new proto.InboundMessage_CompileRequest_Importer({ - importer: {case: 'path', value: p.resolve('.')}, - }); - } else { - // When importer is not set on the host, the compiler will set a - // FileSystemImporter if `url` is set to a file: url or a NoOpImporter. - } - - const request = newCompileRequest(importers, options); - request.input = {case: 'string', value: input}; - return request; -} - -// Creates a compilation request for the given `options` without adding any -// input-specific options. -function newCompileRequest( - importers: ImporterRegistry<'sync' | 'async'>, - options?: Options<'sync' | 'async'> -): proto.InboundMessage_CompileRequest { - const request = new proto.InboundMessage_CompileRequest({ - importers: importers.importers, - globalFunctions: Object.keys(options?.functions ?? {}), - sourceMap: !!options?.sourceMap, - sourceMapIncludeSources: !!options?.sourceMapIncludeSources, - alertColor: options?.alertColor ?? !!supportsColor.stdout, - alertAscii: !!options?.alertAscii, - quietDeps: !!options?.quietDeps, - verbose: !!options?.verbose, - charset: !!(options?.charset ?? true), - }); - - switch (options?.style ?? 'expanded') { - case 'expanded': - request.style = proto.OutputStyle.EXPANDED; - break; - - case 'compressed': - request.style = proto.OutputStyle.COMPRESSED; - break; - - default: - throw new Error(`Unknown options.style: "${options?.style}"`); - } - - return request; -} - -// Spins up a compiler, then sends it a compile request. Returns a promise that -// resolves with the CompileResult. Throws if there were any protocol or -// compilation errors. Shuts down the compiler after compilation. -async function compileRequestAsync( - request: proto.InboundMessage_CompileRequest, - importers: ImporterRegistry<'async'>, - options?: OptionsWithLegacy<'async'> & {legacy?: boolean} -): Promise { - const functions = new FunctionRegistry(options?.functions); - const embeddedCompiler = new AsyncEmbeddedCompiler(); - embeddedCompiler.stderr$.subscribe(data => process.stderr.write(data)); - + const compiler = await initAsyncCompiler(); try { - const dispatcher = createDispatcher<'async'>( - embeddedCompiler.stdout$, - buffer => { - embeddedCompiler.writeStdin(buffer); - }, - { - handleImportRequest: request => importers.import(request), - handleFileImportRequest: request => importers.fileImport(request), - handleCanonicalizeRequest: request => importers.canonicalize(request), - handleFunctionCallRequest: request => functions.call(request), - } - ); - - dispatcher.logEvents$.subscribe(event => handleLogEvent(options, event)); - - return handleCompileResponse( - await new Promise( - (resolve, reject) => - dispatcher.sendCompileRequest(request, (err, response) => { - if (err) { - reject(err); - } else { - resolve(response!); - } - }) - ) - ); + return await compiler.compileAsync(path, options); } finally { - embeddedCompiler.close(); - await embeddedCompiler.exit$; + await compiler.dispose(); } } -// Spins up a compiler, then sends it a compile request. Returns a promise that -// resolves with the CompileResult. Throws if there were any protocol or -// compilation errors. Shuts down the compiler after compilation. -function compileRequestSync( - request: proto.InboundMessage_CompileRequest, - importers: ImporterRegistry<'sync'>, - options?: OptionsWithLegacy<'sync'> -): CompileResult { - const functions = new FunctionRegistry(options?.functions); - const embeddedCompiler = new SyncEmbeddedCompiler(); - embeddedCompiler.stderr$.subscribe(data => process.stderr.write(data)); - +export async function compileStringAsync( + source: string, + options?: StringOptionsWithLegacy<'async'> +): Promise { + const compiler = await initAsyncCompiler(); try { - const dispatcher = createDispatcher<'sync'>( - embeddedCompiler.stdout$, - buffer => { - embeddedCompiler.writeStdin(buffer); - }, - { - handleImportRequest: request => importers.import(request), - handleFileImportRequest: request => importers.fileImport(request), - handleCanonicalizeRequest: request => importers.canonicalize(request), - handleFunctionCallRequest: request => functions.call(request), - } - ); - - dispatcher.logEvents$.subscribe(event => handleLogEvent(options, event)); - - let error: unknown; - let response: proto.OutboundMessage_CompileResponse | undefined; - dispatcher.sendCompileRequest(request, (error_, response_) => { - if (error_) { - error = error_; - } else { - response = response_; - } - }); - - for (;;) { - if (!embeddedCompiler.yield()) { - throw utils.compilerError('Embedded compiler exited unexpectedly.'); - } - - if (error) throw error; - if (response) return handleCompileResponse(response); - } + return await compiler.compileStringAsync(source, options); } finally { - embeddedCompiler.close(); - embeddedCompiler.yieldUntilExit(); - } -} - -/** - * Creates a dispatcher that dispatches messages from the given `stdout` stream. - */ -function createDispatcher( - stdout: Observable, - writeStdin: (buffer: Buffer) => void, - handlers: DispatcherHandlers -): Dispatcher { - const packetTransformer = new PacketTransformer(stdout, writeStdin); - - const messageTransformer = new MessageTransformer( - packetTransformer.outboundProtobufs$, - packet => packetTransformer.writeInboundProtobuf(packet) - ); - - return new Dispatcher( - // Since we only use one compilation per process, we can get away with - // hardcoding a compilation ID. Once we support multiple concurrent - // compilations with the same process, we'll need to ensure each one uses a - // unique ID. - 1, - messageTransformer.outboundMessages$, - message => messageTransformer.writeInboundMessage(message), - handlers - ); -} - -/** Handles a log event according to `options`. */ -function handleLogEvent( - options: OptionsWithLegacy<'sync' | 'async'> | undefined, - event: proto.OutboundMessage_LogEvent -): void { - let span = event.span ? deprotofySourceSpan(event.span) : null; - if (span && options?.legacy) span = removeLegacyImporterFromSpan(span); - let message = event.message; - if (options?.legacy) message = removeLegacyImporter(message); - let formatted = event.formatted; - if (options?.legacy) formatted = removeLegacyImporter(formatted); - - if (event.type === proto.LogEventType.DEBUG) { - if (options?.logger?.debug) { - options.logger.debug(message, { - span: span!, - }); - } else { - console.error(formatted); - } - } else { - if (options?.logger?.warn) { - const params: {deprecation: boolean; span?: SourceSpan; stack?: string} = - { - deprecation: event.type === proto.LogEventType.DEPRECATION_WARNING, - }; - if (span) params.span = span; - - const stack = event.stackTrace; - if (stack) { - params.stack = options?.legacy ? removeLegacyImporter(stack) : stack; - } - - options.logger.warn(message, params); - } else { - console.error(formatted); - } - } -} - -/** - * Converts a `CompileResponse` into a `CompileResult`. - * - * Throws a `SassException` if the compilation failed. - */ -function handleCompileResponse( - response: proto.OutboundMessage_CompileResponse -): CompileResult { - if (response.result.case === 'success') { - const success = response.result.value; - const result: CompileResult = { - css: success.css, - loadedUrls: response.loadedUrls.map(url => new URL(url)), - }; - - const sourceMap = success.sourceMap; - if (sourceMap) result.sourceMap = JSON.parse(sourceMap); - return result; - } else if (response.result.case === 'failure') { - throw new Exception(response.result.value); - } else { - throw utils.compilerError('Compiler sent empty CompileResponse.'); + await compiler.dispose(); } } diff --git a/lib/src/compiler-path.ts b/lib/src/compiler-path.ts index 0e193c95..60675e5b 100644 --- a/lib/src/compiler-path.ts +++ b/lib/src/compiler-path.ts @@ -6,14 +6,29 @@ import * as fs from 'fs'; import * as p from 'path'; import {isErrnoException} from './utils'; +/** + * Detect if the current running node binary is linked with musl libc by + * checking if the binary contains a string like "/.../ld-musl-$ARCH.so" + */ +const isLinuxMusl = function () { + return fs.readFileSync(process.execPath).includes('/ld-musl-'); +}; + /** The full command for the embedded compiler executable. */ export const compilerCommand = (() => { + const platform = + process.platform === 'linux' && isLinuxMusl() + ? 'linux-musl' + : (process.platform as string); + + const arch = process.arch; + // find for development for (const path of ['vendor', '../../../lib/src/vendor']) { const executable = p.resolve( __dirname, path, - `dart-sass/sass${process.platform === 'win32' ? '.bat' : ''}` + `dart-sass/sass${platform === 'win32' ? '.bat' : ''}` ); if (fs.existsSync(executable)) return [executable]; @@ -22,13 +37,11 @@ export const compilerCommand = (() => { try { return [ require.resolve( - `sass-embedded-${process.platform}-${process.arch}/` + - 'dart-sass/src/dart' + - (process.platform === 'win32' ? '.exe' : '') + `sass-embedded-${platform}-${arch}/dart-sass/src/dart` + + (platform === 'win32' ? '.exe' : '') ), require.resolve( - `sass-embedded-${process.platform}-${process.arch}/` + - 'dart-sass/src/sass.snapshot' + `sass-embedded-${platform}-${arch}/dart-sass/src/sass.snapshot` ), ]; } catch (ignored) { @@ -38,9 +51,8 @@ export const compilerCommand = (() => { try { return [ require.resolve( - `sass-embedded-${process.platform}-${process.arch}/` + - 'dart-sass/sass' + - (process.platform === 'win32' ? '.bat' : '') + `sass-embedded-${platform}-${arch}/dart-sass/sass` + + (platform === 'win32' ? '.bat' : '') ), ]; } catch (e: unknown) { @@ -52,7 +64,7 @@ export const compilerCommand = (() => { throw new Error( "Embedded Dart Sass couldn't find the embedded compiler executable. " + 'Please make sure the optional dependency ' + - `sass-embedded-${process.platform}-${process.arch} is installed in ` + + `sass-embedded-${platform}-${arch} is installed in ` + 'node_modules.' ); })(); diff --git a/lib/src/compiler.test.ts b/lib/src/compiler.test.ts new file mode 100644 index 00000000..58fbc61f --- /dev/null +++ b/lib/src/compiler.test.ts @@ -0,0 +1,130 @@ +// Copyright 2024 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 * as fs from 'fs'; +import {chdir} from 'process'; +import {AsyncCompiler, initAsyncCompiler} from './compiler/async'; +import * as compilerModule from './compiler/utils'; +import {Compiler, initCompiler} from './compiler/sync'; + +const createDispatcher = jest.spyOn(compilerModule, 'createDispatcher'); +function getIdHistory() { + return createDispatcher.mock.calls.map(([id]) => id); +} + +afterEach(() => { + createDispatcher.mockClear(); +}); + +describe('compiler', () => { + let compiler: Compiler; + const importers = [ + { + canonicalize: () => new URL('foo:bar'), + load: () => ({ + contents: compiler.compileString('').css, + syntax: 'scss' as const, + }), + }, + ]; + + beforeEach(() => { + compiler = initCompiler(); + }); + + afterEach(() => { + compiler.dispose(); + }); + + it('calls functions independently', () => { + const [logger1, logger2] = [jest.fn(), jest.fn()]; + compiler.compileString('@debug ""', {logger: {debug: logger1}}); + compiler.compileString('@debug ""', {logger: {debug: logger2}}); + expect(logger1).toHaveBeenCalledTimes(1); + expect(logger2).toHaveBeenCalledTimes(1); + }); + + it('handles the removal of the working directory', () => { + const oldDir = fs.mkdtempSync('sass-spec-'); + chdir(oldDir); + const tmpCompiler = initCompiler(); + chdir('..'); + fs.rmSync(oldDir, {recursive: true}); + fs.writeFileSync('foo.scss', 'a {b: c}'); + expect(() => tmpCompiler.compile('foo.scss')).not.toThrow(); + tmpCompiler.dispose(); + fs.rmSync('foo.scss'); + }); + + describe('compilation ID', () => { + it('resets after callback compilations complete', () => { + compiler.compileString('@import "foo"', {importers}); + compiler.compileString(''); + expect(getIdHistory()).toEqual([1, 2, 1]); + }); + + it('keeps working after failed compilations', () => { + expect(() => compiler.compileString('invalid')).toThrow(); + compiler.compileString('@import "foo"', {importers}); + expect(getIdHistory()).toEqual([1, 1, 2]); + }); + }); +}); + +describe('asyncCompiler', () => { + let asyncCompiler: AsyncCompiler; + + beforeEach(async () => { + asyncCompiler = await initAsyncCompiler(); + }); + + afterEach(async () => { + await asyncCompiler.dispose(); + }); + + it('handles the removal of the working directory', async () => { + const oldDir = fs.mkdtempSync('sass-spec-'); + chdir(oldDir); + const tmpCompiler = await initAsyncCompiler(); + chdir('..'); + fs.rmSync(oldDir, {recursive: true}); + fs.writeFileSync('foo.scss', 'a {b: c}'); + await expect(tmpCompiler.compileAsync('foo.scss')).resolves.not.toThrow(); + await tmpCompiler.dispose(); + fs.rmSync('foo.scss'); + }); + + it('calls functions independently', async () => { + const [logger1, logger2] = [jest.fn(), jest.fn()]; + await asyncCompiler.compileStringAsync('@debug ""', { + logger: {debug: logger1}, + }); + await asyncCompiler.compileStringAsync('@debug ""', { + logger: {debug: logger2}, + }); + expect(logger1).toHaveBeenCalledTimes(1); + expect(logger2).toHaveBeenCalledTimes(1); + }); + + describe('compilation ID', () => { + it('resets after concurrent compilations complete', async () => { + await Promise.all( + Array.from({length: 10}, () => asyncCompiler.compileStringAsync('')) + ); + await asyncCompiler.compileStringAsync(''); + expect(getIdHistory()).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 1]); + }); + + it('keeps working after failed compilations', async () => { + await expect( + asyncCompiler.compileStringAsync('invalid') + ).rejects.toThrow(); + await Promise.all([ + asyncCompiler.compileStringAsync(''), + asyncCompiler.compileStringAsync(''), + ]); + expect(getIdHistory()).toEqual([1, 1, 2]); + }); + }); +}); diff --git a/lib/src/compiler/async.ts b/lib/src/compiler/async.ts new file mode 100644 index 00000000..6b4d77a4 --- /dev/null +++ b/lib/src/compiler/async.ts @@ -0,0 +1,189 @@ +// Copyright 2024 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 {spawn} from 'child_process'; +import {Observable} from 'rxjs'; +import {takeUntil} from 'rxjs/operators'; + +import * as path from 'path'; +import { + OptionsWithLegacy, + StringOptionsWithLegacy, + createDispatcher, + handleCompileResponse, + handleLogEvent, + newCompilePathRequest, + newCompileStringRequest, +} from './utils'; +import {compilerCommand} from '../compiler-path'; +import {FunctionRegistry} from '../function-registry'; +import {ImporterRegistry} from '../importer-registry'; +import {MessageTransformer} from '../message-transformer'; +import {PacketTransformer} from '../packet-transformer'; +import * as utils from '../utils'; +import * as proto from '../vendor/embedded_sass_pb'; +import {CompileResult} from '../vendor/sass'; + +/** + * Flag allowing the constructor passed by `initAsyncCompiler` so we can + * differentiate and throw an error if the `AsyncCompiler` is constructed via + * `new AsyncCompiler`. + */ +const initFlag = Symbol(); + +/** An asynchronous wrapper for the embedded Sass compiler */ +export class AsyncCompiler { + /** The underlying process that's being wrapped. */ + private readonly process = spawn( + compilerCommand[0], + [...compilerCommand.slice(1), '--embedded'], + { + // Use the command's cwd so the compiler survives the removal of the + // current working directory. + // https://github.com/sass/embedded-host-node/pull/261#discussion_r1438712923 + cwd: path.dirname(compilerCommand[0]), + windowsHide: true, + } + ); + + /** The next compilation ID. */ + private compilationId = 1; + + /** A list of active compilations. */ + private readonly compilations: Set< + Promise + > = new Set(); + + /** Whether the underlying compiler has already exited. */ + private disposed = false; + + /** Reusable message transformer for all compilations. */ + private readonly messageTransformer: MessageTransformer; + + /** The child process's exit event. */ + private readonly exit$ = new Promise(resolve => { + this.process.on('exit', code => resolve(code)); + }); + + /** The buffers emitted by the child process's stdout. */ + private readonly stdout$ = new Observable(observer => { + this.process.stdout.on('data', buffer => observer.next(buffer)); + }).pipe(takeUntil(this.exit$)); + + /** The buffers emitted by the child process's stderr. */ + private readonly stderr$ = new Observable(observer => { + this.process.stderr.on('data', buffer => observer.next(buffer)); + }).pipe(takeUntil(this.exit$)); + + /** Writes `buffer` to the child process's stdin. */ + private writeStdin(buffer: Buffer): void { + this.process.stdin.write(buffer); + } + + /** Guards against using a disposed compiler. */ + private throwIfDisposed(): void { + if (this.disposed) { + throw utils.compilerError('Async compiler has already been disposed.'); + } + } + + /** + * Sends a compile request to the child process and returns a Promise that + * resolves with the CompileResult. Rejects the promise if there were any + * protocol or compilation errors. + */ + private async compileRequestAsync( + request: proto.InboundMessage_CompileRequest, + importers: ImporterRegistry<'async'>, + options?: OptionsWithLegacy<'async'> & {legacy?: boolean} + ): Promise { + const functions = new FunctionRegistry(options?.functions); + + const dispatcher = createDispatcher<'async'>( + this.compilationId++, + this.messageTransformer, + { + handleImportRequest: request => importers.import(request), + handleFileImportRequest: request => importers.fileImport(request), + handleCanonicalizeRequest: request => importers.canonicalize(request), + handleFunctionCallRequest: request => functions.call(request), + } + ); + dispatcher.logEvents$.subscribe(event => handleLogEvent(options, event)); + + const compilation = new Promise( + (resolve, reject) => + dispatcher.sendCompileRequest(request, (err, response) => { + this.compilations.delete(compilation); + // Reset the compilation ID when the compiler goes idle (no active + // compilations) to avoid overflowing it. + // https://github.com/sass/embedded-host-node/pull/261#discussion_r1429266794 + if (this.compilations.size === 0) this.compilationId = 1; + if (err) { + reject(err); + } else { + resolve(response!); + } + }) + ); + this.compilations.add(compilation); + + return handleCompileResponse(await compilation); + } + + /** Initialize resources shared across compilations. */ + constructor(flag: Symbol | undefined) { + if (flag !== initFlag) { + throw utils.compilerError( + 'AsyncCompiler can not be directly constructed. ' + + 'Please use `sass.initAsyncCompiler()` instead.' + ); + } + this.stderr$.subscribe(data => process.stderr.write(data)); + const packetTransformer = new PacketTransformer(this.stdout$, buffer => { + this.writeStdin(buffer); + }); + this.messageTransformer = new MessageTransformer( + packetTransformer.outboundProtobufs$, + packet => packetTransformer.writeInboundProtobuf(packet) + ); + } + + compileAsync( + path: string, + options?: OptionsWithLegacy<'async'> + ): Promise { + this.throwIfDisposed(); + const importers = new ImporterRegistry(options); + return this.compileRequestAsync( + newCompilePathRequest(path, importers, options), + importers, + options + ); + } + + compileStringAsync( + source: string, + options?: StringOptionsWithLegacy<'async'> + ): Promise { + this.throwIfDisposed(); + const importers = new ImporterRegistry(options); + return this.compileRequestAsync( + newCompileStringRequest(source, importers, options), + importers, + options + ); + } + + async dispose(): Promise { + this.disposed = true; + await Promise.all(this.compilations); + this.process.stdin.end(); + await this.exit$; + } +} + +export async function initAsyncCompiler(): Promise { + return new AsyncCompiler(initFlag); +} diff --git a/lib/src/compiler/sync.ts b/lib/src/compiler/sync.ts new file mode 100644 index 00000000..50b7bb33 --- /dev/null +++ b/lib/src/compiler/sync.ts @@ -0,0 +1,201 @@ +// Copyright 2024 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 {Subject} from 'rxjs'; + +import * as path from 'path'; +import { + OptionsWithLegacy, + createDispatcher, + handleCompileResponse, + handleLogEvent, + newCompilePathRequest, + newCompileStringRequest, +} from './utils'; +import {compilerCommand} from '../compiler-path'; +import {Dispatcher} from '../dispatcher'; +import {FunctionRegistry} from '../function-registry'; +import {ImporterRegistry} from '../importer-registry'; +import {MessageTransformer} from '../message-transformer'; +import {PacketTransformer} from '../packet-transformer'; +import {SyncProcess} from '../sync-process'; +import * as utils from '../utils'; +import * as proto from '../vendor/embedded_sass_pb'; +import {CompileResult} from '../vendor/sass/compile'; +import {Options} from '../vendor/sass/options'; + +/** + * Flag allowing the constructor passed by `initCompiler` so we can + * differentiate and throw an error if the `Compiler` is constructed via `new + * Compiler`. + */ +const initFlag = Symbol(); + +/** A synchronous wrapper for the embedded Sass compiler */ +export class Compiler { + /** The underlying process that's being wrapped. */ + private readonly process = new SyncProcess( + compilerCommand[0], + [...compilerCommand.slice(1), '--embedded'], + { + // Use the command's cwd so the compiler survives the removal of the + // current working directory. + // https://github.com/sass/embedded-host-node/pull/261#discussion_r1438712923 + cwd: path.dirname(compilerCommand[0]), + windowsHide: true, + } + ); + + /** The next compilation ID. */ + private compilationId = 1; + + /** A list of active dispatchers. */ + private readonly dispatchers: Set> = new Set(); + + /** The buffers emitted by the child process's stdout. */ + private readonly stdout$ = new Subject(); + + /** The buffers emitted by the child process's stderr. */ + private readonly stderr$ = new Subject(); + + /** Whether the underlying compiler has already exited. */ + private disposed = false; + + /** Reusable message transformer for all compilations. */ + private readonly messageTransformer: MessageTransformer; + + /** Writes `buffer` to the child process's stdin. */ + private writeStdin(buffer: Buffer): void { + this.process.stdin.write(buffer); + } + + /** Yields the next event from the underlying process. */ + private yield(): boolean { + const event = this.process.yield(); + switch (event.type) { + case 'stdout': + this.stdout$.next(event.data); + return true; + + case 'stderr': + this.stderr$.next(event.data); + return true; + + case 'exit': + this.disposed = true; + return false; + } + } + + /** Blocks until the underlying process exits. */ + private yieldUntilExit(): void { + while (!this.disposed) { + this.yield(); + } + } + + /** + * Sends a compile request to the child process and returns the CompileResult. + * Throws if there were any protocol or compilation errors. + */ + private compileRequestSync( + request: proto.InboundMessage_CompileRequest, + importers: ImporterRegistry<'sync'>, + options?: OptionsWithLegacy<'sync'> + ): CompileResult { + const functions = new FunctionRegistry(options?.functions); + + const dispatcher = createDispatcher<'sync'>( + this.compilationId++, + this.messageTransformer, + { + handleImportRequest: request => importers.import(request), + handleFileImportRequest: request => importers.fileImport(request), + handleCanonicalizeRequest: request => importers.canonicalize(request), + handleFunctionCallRequest: request => functions.call(request), + } + ); + this.dispatchers.add(dispatcher); + + dispatcher.logEvents$.subscribe(event => handleLogEvent(options, event)); + + let error: unknown; + let response: proto.OutboundMessage_CompileResponse | undefined; + dispatcher.sendCompileRequest(request, (error_, response_) => { + this.dispatchers.delete(dispatcher); + // Reset the compilation ID when the compiler goes idle (no active + // dispatchers) to avoid overflowing it. + // https://github.com/sass/embedded-host-node/pull/261#discussion_r1429266794 + if (this.dispatchers.size === 0) this.compilationId = 1; + if (error_) { + error = error_; + } else { + response = response_; + } + }); + + for (;;) { + if (!this.yield()) { + throw utils.compilerError('Embedded compiler exited unexpectedly.'); + } + + if (error) throw error; + if (response) return handleCompileResponse(response); + } + } + + /** Guards against using a disposed compiler. */ + private throwIfDisposed(): void { + if (this.disposed) { + throw utils.compilerError('Sync compiler has already been disposed.'); + } + } + + /** Initialize resources shared across compilations. */ + constructor(flag: Symbol | undefined) { + if (flag !== initFlag) { + throw utils.compilerError( + 'Compiler can not be directly constructed. ' + + 'Please use `sass.initAsyncCompiler()` instead.' + ); + } + this.stderr$.subscribe(data => process.stderr.write(data)); + const packetTransformer = new PacketTransformer(this.stdout$, buffer => { + this.writeStdin(buffer); + }); + this.messageTransformer = new MessageTransformer( + packetTransformer.outboundProtobufs$, + packet => packetTransformer.writeInboundProtobuf(packet) + ); + } + + compile(path: string, options?: Options<'sync'>): CompileResult { + this.throwIfDisposed(); + const importers = new ImporterRegistry(options); + return this.compileRequestSync( + newCompilePathRequest(path, importers, options), + importers, + options + ); + } + + compileString(source: string, options?: Options<'sync'>): CompileResult { + this.throwIfDisposed(); + const importers = new ImporterRegistry(options); + return this.compileRequestSync( + newCompileStringRequest(source, importers, options), + importers, + options + ); + } + + dispose(): void { + this.process.stdin.end(); + this.yieldUntilExit(); + } +} + +export function initCompiler(): Compiler { + return new Compiler(initFlag); +} diff --git a/lib/src/compiler/utils.ts b/lib/src/compiler/utils.ts new file mode 100644 index 00000000..135de80f --- /dev/null +++ b/lib/src/compiler/utils.ts @@ -0,0 +1,203 @@ +// Copyright 2024 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 * as p from 'path'; +import * as supportsColor from 'supports-color'; +import {deprotofySourceSpan} from '../deprotofy-span'; +import {Dispatcher, DispatcherHandlers} from '../dispatcher'; +import {Exception} from '../exception'; +import {ImporterRegistry} from '../importer-registry'; +import { + legacyImporterProtocol, + removeLegacyImporter, + removeLegacyImporterFromSpan, +} from '../legacy/utils'; +import {Logger} from '../logger'; +import {MessageTransformer} from '../message-transformer'; +import * as utils from '../utils'; +import * as proto from '../vendor/embedded_sass_pb'; +import {SourceSpan} from '../vendor/sass'; +import {CompileResult} from '../vendor/sass/compile'; +import {Options, StringOptions} from '../vendor/sass/options'; + +/** + * Allow the legacy API to pass in an option signaling to the modern API that + * it's being run in legacy mode. + * + * This is not intended for API users to pass in, and may be broken without + * warning in the future. + */ +export type OptionsWithLegacy = Options & { + legacy?: boolean; +}; + +/** + * Allow the legacy API to pass in an option signaling to the modern API that + * it's being run in legacy mode. + * + * This is not intended for API users to pass in, and may be broken without + * warning in the future. + */ +export type StringOptionsWithLegacy = + StringOptions & {legacy?: boolean}; + +/** + * Creates a dispatcher that dispatches messages from the given `stdout` stream. + */ +export function createDispatcher( + compilationId: number, + messageTransformer: MessageTransformer, + handlers: DispatcherHandlers +): Dispatcher { + return new Dispatcher( + compilationId, + messageTransformer.outboundMessages$, + message => messageTransformer.writeInboundMessage(message), + handlers + ); +} + +// Creates a compilation request for the given `options` without adding any +// input-specific options. +function newCompileRequest( + importers: ImporterRegistry<'sync' | 'async'>, + options?: Options<'sync' | 'async'> +): proto.InboundMessage_CompileRequest { + const request = new proto.InboundMessage_CompileRequest({ + importers: importers.importers, + globalFunctions: Object.keys(options?.functions ?? {}), + sourceMap: !!options?.sourceMap, + sourceMapIncludeSources: !!options?.sourceMapIncludeSources, + alertColor: options?.alertColor ?? !!supportsColor.stdout, + alertAscii: !!options?.alertAscii, + quietDeps: !!options?.quietDeps, + verbose: !!options?.verbose, + charset: !!(options?.charset ?? true), + silent: options?.logger === Logger.silent, + }); + + switch (options?.style ?? 'expanded') { + case 'expanded': + request.style = proto.OutputStyle.EXPANDED; + break; + + case 'compressed': + request.style = proto.OutputStyle.COMPRESSED; + break; + + default: + throw new Error(`Unknown options.style: "${options?.style}"`); + } + + return request; +} + +// Creates a request for compiling a file. +export function newCompilePathRequest( + path: string, + importers: ImporterRegistry<'sync' | 'async'>, + options?: Options<'sync' | 'async'> +): proto.InboundMessage_CompileRequest { + const absPath = p.resolve(path); + const request = newCompileRequest(importers, options); + request.input = {case: 'path', value: absPath}; + return request; +} + +// Creates a request for compiling a string. +export function newCompileStringRequest( + source: string, + importers: ImporterRegistry<'sync' | 'async'>, + options?: StringOptions<'sync' | 'async'> +): proto.InboundMessage_CompileRequest { + const input = new proto.InboundMessage_CompileRequest_StringInput({ + source, + syntax: utils.protofySyntax(options?.syntax ?? 'scss'), + }); + + const url = options?.url?.toString(); + if (url && url !== legacyImporterProtocol) { + input.url = url; + } + + if (options && 'importer' in options && options.importer) { + input.importer = importers.register(options.importer); + } else if (url === legacyImporterProtocol) { + input.importer = new proto.InboundMessage_CompileRequest_Importer({ + importer: {case: 'path', value: p.resolve('.')}, + }); + } else { + // When importer is not set on the host, the compiler will set a + // FileSystemImporter if `url` is set to a file: url or a NoOpImporter. + } + + const request = newCompileRequest(importers, options); + request.input = {case: 'string', value: input}; + return request; +} + +/** Handles a log event according to `options`. */ +export function handleLogEvent( + options: OptionsWithLegacy<'sync' | 'async'> | undefined, + event: proto.OutboundMessage_LogEvent +): void { + let span = event.span ? deprotofySourceSpan(event.span) : null; + if (span && options?.legacy) span = removeLegacyImporterFromSpan(span); + let message = event.message; + if (options?.legacy) message = removeLegacyImporter(message); + let formatted = event.formatted; + if (options?.legacy) formatted = removeLegacyImporter(formatted); + + if (event.type === proto.LogEventType.DEBUG) { + if (options?.logger?.debug) { + options.logger.debug(message, { + span: span!, + }); + } else { + console.error(formatted); + } + } else { + if (options?.logger?.warn) { + const params: {deprecation: boolean; span?: SourceSpan; stack?: string} = + { + deprecation: event.type === proto.LogEventType.DEPRECATION_WARNING, + }; + if (span) params.span = span; + + const stack = event.stackTrace; + if (stack) { + params.stack = options?.legacy ? removeLegacyImporter(stack) : stack; + } + + options.logger.warn(message, params); + } else { + console.error(formatted); + } + } +} + +/** + * Converts a `CompileResponse` into a `CompileResult`. + * + * Throws a `SassException` if the compilation failed. + */ +export function handleCompileResponse( + response: proto.OutboundMessage_CompileResponse +): CompileResult { + if (response.result.case === 'success') { + const success = response.result.value; + const result: CompileResult = { + css: success.css, + loadedUrls: response.loadedUrls.map(url => new URL(url)), + }; + + const sourceMap = success.sourceMap; + if (sourceMap) result.sourceMap = JSON.parse(sourceMap); + return result; + } else if (response.result.case === 'failure') { + throw new Exception(response.result.value); + } else { + throw utils.compilerError('Compiler sent empty CompileResponse.'); + } +} diff --git a/lib/src/dispatcher.ts b/lib/src/dispatcher.ts index 08d1271a..a99dee96 100644 --- a/lib/src/dispatcher.ts +++ b/lib/src/dispatcher.ts @@ -3,13 +3,19 @@ // https://opensource.org/licenses/MIT. import {Observable, Subject} from 'rxjs'; -import {filter, map, mergeMap} from 'rxjs/operators'; +import {filter, map, mergeMap, takeUntil} from 'rxjs/operators'; import {OutboundResponse} from './messages'; import * as proto from './vendor/embedded_sass_pb'; import {RequestTracker} from './request-tracker'; import {PromiseOr, compilerError, thenOr, hostError} from './utils'; +// A callback that accepts a response or error. +type ResponseCallback = ( + err: unknown, + response: proto.OutboundMessage_CompileResponse | undefined +) => void; + /** * Dispatches requests, responses, and events for a single compilation. * @@ -39,6 +45,11 @@ export class Dispatcher { // dispatching messages, this completes. private readonly messages$ = new Subject(); + // Subject to unsubscribe from all outbound messages to prevent past + // dispatchers with compilation IDs reused by future dispatchers from + // receiving messages intended for future dispatchers. + private readonly unsubscribe$ = new Subject(); + // If the dispatcher encounters an error, this errors out. It is publicly // exposed as a readonly Observable. private readonly errorInternal$ = new Subject(); @@ -82,7 +93,8 @@ export class Dispatcher { return result instanceof Promise ? result.then(() => message) : [message]; - }) + }), + takeUntil(this.unsubscribe$) ) .subscribe({ next: message => this.messages$.next(message), @@ -96,7 +108,8 @@ export class Dispatcher { /** * Sends a CompileRequest inbound. Passes the corresponding outbound - * CompileResponse or an error to `callback`. + * CompileResponse or an error to `callback` and unsubscribes from all + * outbound events. * * This uses an old-style callback argument so that it can work either * synchronously or asynchronously. If the underlying stdout stream emits @@ -104,13 +117,16 @@ export class Dispatcher { */ sendCompileRequest( request: proto.InboundMessage_CompileRequest, - callback: ( - err: unknown, - response: proto.OutboundMessage_CompileResponse | undefined - ) => void + callback: ResponseCallback ): void { + // Call the callback but unsubscribe first + const callback_: ResponseCallback = (err, response) => { + this.unsubscribe(); + return callback(err, response); + }; + if (this.messages$.isStopped) { - callback(new Error('Tried writing to closed dispatcher'), undefined); + callback_(new Error('Tried writing to closed dispatcher'), undefined); return; } @@ -119,9 +135,11 @@ export class Dispatcher { filter(message => message.message.case === 'compileResponse'), map(message => message.message.value as OutboundResponse) ) - .subscribe({next: response => callback(null, response)}); + .subscribe({next: response => callback_(null, response)}); - this.error$.subscribe({error: error => callback(error, undefined)}); + this.error$.subscribe({ + error: error => callback_(error, undefined), + }); try { this.writeInboundMessage([ @@ -135,11 +153,18 @@ export class Dispatcher { } } + // Stop the outbound message subscription. + private unsubscribe(): void { + this.unsubscribe$.next(undefined); + this.unsubscribe$.complete(); + } + // Rejects with `error` all promises awaiting an outbound response, and // silently closes all subscriptions awaiting outbound events. private throwAndClose(error: unknown): void { this.messages$.complete(); this.errorInternal$.error(error); + this.unsubscribe(); } // Keeps track of all outbound messages. If the outbound `message` contains a diff --git a/lib/src/function-registry.ts b/lib/src/function-registry.ts index d82acc4a..0c4a3b81 100644 --- a/lib/src/function-registry.ts +++ b/lib/src/function-registry.ts @@ -12,12 +12,6 @@ import {PromiseOr, catchOr, compilerError, thenOr} from './utils'; import {Protofier} from './protofier'; import {Value} from './value'; -/** - * The next ID to use for a function. The embedded protocol requires that - * function IDs be globally unique. - */ -let nextFunctionID = 0; - /** * Tracks functions that are defined on the host so that the compiler can * execute them. @@ -27,6 +21,9 @@ export class FunctionRegistry { private readonly functionsById = new Map>(); private readonly idsByFunction = new Map, number>(); + /** The next ID to use for a function. */ + private id = 0; + constructor(functionsBySignature?: Record>) { for (const [signature, fn] of Object.entries(functionsBySignature ?? {})) { const openParen = signature.indexOf('('); @@ -41,8 +38,8 @@ export class FunctionRegistry { /** Registers `fn` as a function that can be called using the returned ID. */ register(fn: CustomFunction): number { return utils.putIfAbsent(this.idsByFunction, fn, () => { - const id = nextFunctionID; - nextFunctionID += 1; + const id = this.id; + this.id += 1; this.functionsById.set(id, fn); return id; }); diff --git a/lib/src/importer-registry.ts b/lib/src/importer-registry.ts index a3d825e0..9a1cb337 100644 --- a/lib/src/importer-registry.ts +++ b/lib/src/importer-registry.ts @@ -2,6 +2,7 @@ // MIT-style license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +import {createRequire} from 'module'; import * as p from 'path'; import {URL} from 'url'; import {inspect} from 'util'; @@ -11,6 +12,32 @@ import {FileImporter, Importer, Options} from './vendor/sass'; import * as proto from './vendor/embedded_sass_pb'; import {catchOr, thenOr, PromiseOr} from './utils'; +const entryPointDirectoryKey = Symbol(); + +export class NodePackageImporter { + readonly [entryPointDirectoryKey]: string; + + constructor(entryPointDirectory?: string) { + entryPointDirectory = entryPointDirectory + ? p.resolve(entryPointDirectory) + : require.main?.filename + ? p.dirname(require.main.filename) + : // TODO: Find a way to use `import.meta.main` once + // https://github.com/nodejs/node/issues/49440 is done. + process.argv[1] + ? createRequire(process.argv[1]).resolve(process.argv[1]) + : undefined; + if (!entryPointDirectory) { + throw new Error( + 'The Node package importer cannot determine an entry point ' + + 'because `require.main.filename` is not defined. ' + + 'Please provide an `entryPointDirectory` to the `NodePackageImporter`.' + ); + } + this[entryPointDirectoryKey] = entryPointDirectory; + } +} + /** * A registry of importers defined in the host that can be invoked by the * compiler. @@ -30,7 +57,11 @@ export class ImporterRegistry { constructor(options?: Options) { this.importers = (options?.importers ?? []) - .map(importer => this.register(importer)) + .map(importer => + this.register( + importer as Importer | FileImporter | NodePackageImporter + ) + ) .concat( (options?.loadPaths ?? []).map( path => @@ -43,10 +74,17 @@ export class ImporterRegistry { /** Converts an importer to a proto without adding it to `this.importers`. */ register( - importer: Importer | FileImporter + importer: Importer | FileImporter | NodePackageImporter ): proto.InboundMessage_CompileRequest_Importer { const message = new proto.InboundMessage_CompileRequest_Importer(); - if ('canonicalize' in importer) { + if (importer instanceof NodePackageImporter) { + const importerMessage = new proto.NodePackageImporter(); + importerMessage.entryPointDirectory = importer[entryPointDirectoryKey]; + message.importer = { + case: 'nodePackageImporter', + value: importerMessage, + }; + } else if ('canonicalize' in importer) { if ('findFileUrl' in importer) { throw new Error( 'Importer may not contain both canonicalize() and findFileUrl(): ' + diff --git a/lib/src/legacy/importer.ts b/lib/src/legacy/importer.ts index 791cfc12..97ef9117 100644 --- a/lib/src/legacy/importer.ts +++ b/lib/src/legacy/importer.ts @@ -216,8 +216,8 @@ export class LegacyImporterWrapper const syntax = canonicalUrl.pathname.endsWith('.sass') ? 'indented' : canonicalUrl.pathname.endsWith('.css') - ? 'css' - : 'scss'; + ? 'css' + : 'scss'; let contents = this.lastContents ?? diff --git a/lib/src/legacy/index.ts b/lib/src/legacy/index.ts index 8cc381e7..404937cf 100644 --- a/lib/src/legacy/index.ts +++ b/lib/src/legacy/index.ts @@ -5,6 +5,7 @@ import * as fs from 'fs'; import * as p from 'path'; import {pathToFileURL, URL} from 'url'; +import {NodePackageImporter} from '../importer-registry'; import {Exception} from '../exception'; import { @@ -31,6 +32,8 @@ import { LegacyStringOptions, Options, StringOptions, + Importer, + FileImporter, } from '../vendor/sass'; import {wrapFunction} from './value/wrap'; import {endOfLoadProtocol, LegacyImporterWrapper} from './importer'; @@ -158,7 +161,10 @@ function convertOptions( return { functions, - importers, + importers: + options.pkgImporter instanceof NodePackageImporter + ? [options.pkgImporter, ...(importers ?? [])] + : importers, sourceMap: wasSourceMapRequested(options), sourceMapIncludeSources: options.sourceMapContents, loadPaths: importers ? undefined : options.includePaths, @@ -178,6 +184,12 @@ function convertStringOptions( ): StringOptions & {legacy: true} { const modernOptions = convertOptions(options, sync); + // Find the first non-NodePackageImporter to pass as legacy `importer` option. + // NodePackageImporter will be passed in `modernOptions.importers`. + const importer = modernOptions.importers?.find( + _importer => !(_importer instanceof NodePackageImporter) + ) as Importer | FileImporter; + return { ...modernOptions, url: options.file @@ -185,7 +197,7 @@ function convertStringOptions( ? pathToLegacyFileUrl(options.file) : pathToFileURL(options.file) : new URL(legacyImporterProtocol), - importer: modernOptions.importers ? modernOptions.importers[0] : undefined, + importer, syntax: options.indentedSyntax ? 'indented' : 'scss', }; } diff --git a/lib/src/legacy/utils.ts b/lib/src/legacy/utils.ts index 05ffe42b..7a72472f 100644 --- a/lib/src/legacy/utils.ts +++ b/lib/src/legacy/utils.ts @@ -22,34 +22,34 @@ export const legacyImporterProtocol = 'legacy-importer:'; */ export const legacyImporterProtocolPrefix = 'legacy-importer-'; -/// A regular expression that matches legacy importer protocol syntax that -/// should be removed from human-readable messages. +// A regular expression that matches legacy importer protocol syntax that +// should be removed from human-readable messages. const removeLegacyImporterRegExp = new RegExp( `${legacyImporterProtocol}|${legacyImporterProtocolPrefix}`, 'g' ); -/// Returns `string` with all instances of legacy importer syntax removed. +// Returns `string` with all instances of legacy importer syntax removed. export function removeLegacyImporter(string: string): string { return string.replace(removeLegacyImporterRegExp, ''); } -/// Returns a copy of [span] with the URL updated to remove legacy importer -/// syntax. +// Returns a copy of [span] with the URL updated to remove legacy importer +// syntax. export function removeLegacyImporterFromSpan(span: SourceSpan): SourceSpan { if (!span.url) return span; return {...span, url: new URL(removeLegacyImporter(span.url.toString()))}; } -/// Converts [path] to a `file:` URL and adds the [legacyImporterProtocolPrefix] -/// to the beginning so we can distinguish it from manually-specified absolute -/// `file:` URLs. +// Converts [path] to a `file:` URL and adds the [legacyImporterProtocolPrefix] +// to the beginning so we can distinguish it from manually-specified absolute +// `file:` URLs. export function pathToLegacyFileUrl(path: string): URL { return new URL(`${legacyImporterProtocolPrefix}${pathToFileURL(path)}`); } -/// Converts a `file:` URL with [legacyImporterProtocolPrefix] to the filesystem -/// path which it represents. +// Converts a `file:` URL with [legacyImporterProtocolPrefix] to the filesystem +// path which it represents. export function legacyFileUrlToPath(url: URL): string { assert.equal(url.protocol, legacyImporterFileProtocol); const originalUrl = url diff --git a/lib/src/logger.ts b/lib/src/logger.ts new file mode 100644 index 00000000..2a646d2d --- /dev/null +++ b/lib/src/logger.ts @@ -0,0 +1,7 @@ +// Copyright 2024 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. + +export const Logger = { + silent: {warn() {}, debug() {}}, +}; diff --git a/lib/src/sync-compiler.ts b/lib/src/sync-compiler.ts deleted file mode 100644 index 2950fbdc..00000000 --- a/lib/src/sync-compiler.ts +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright 2021 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 {Subject} from 'rxjs'; - -import {SyncProcess} from './sync-process'; -import {compilerCommand} from './compiler-path'; - -/** - * A synchronous wrapper for the embedded Sass compiler that exposes its stdio - * streams as Observables. - */ -export class SyncEmbeddedCompiler { - /** The underlying process that's being wrapped. */ - private readonly process = new SyncProcess( - compilerCommand[0], - [...compilerCommand.slice(1), '--embedded'], - {windowsHide: true} - ); - - /** The buffers emitted by the child process's stdout. */ - readonly stdout$ = new Subject(); - - /** The buffers emitted by the child process's stderr. */ - readonly stderr$ = new Subject(); - - /** Whether the underlying compiler has already exited. */ - private exited = false; - - /** Writes `buffer` to the child process's stdin. */ - writeStdin(buffer: Buffer): void { - this.process.stdin.write(buffer); - } - - yield(): boolean { - const event = this.process.yield(); - switch (event.type) { - case 'stdout': - this.stdout$.next(event.data); - return true; - - case 'stderr': - this.stderr$.next(event.data); - return true; - - case 'exit': - this.exited = true; - return false; - } - } - - /** Blocks until the underlying process exits. */ - yieldUntilExit(): void { - while (!this.exited) { - this.yield(); - } - } - - /** Kills the child process, cleaning up all associated Observables. */ - close() { - this.process.stdin.end(); - } -} diff --git a/lib/src/utils.test.ts b/lib/src/utils.test.ts index 0e7f29c7..35565fe5 100644 --- a/lib/src/utils.test.ts +++ b/lib/src/utils.test.ts @@ -5,7 +5,9 @@ describe('utils', () => { describe('pathToUrlString', () => { it('encode relative path like `pathToFileURL`', () => { const baseURL = pathToFileURL('').toString(); - for (let i = 0; i < 128; i++) { + // Skip charcodes 0-32 to work around Node trailing whitespace regression: + // https://github.com/nodejs/node/issues/51167 + for (let i = 33; i < 128; i++) { const char = String.fromCharCode(i); const filename = `${i}-${char}`; expect(pathToUrlString(filename)).toEqual( diff --git a/lib/src/value/color.ts b/lib/src/value/color.ts index b8bcaa36..23294b24 100644 --- a/lib/src/value/color.ts +++ b/lib/src/value/color.ts @@ -882,9 +882,7 @@ export class SassColor extends Value { const color = this.color.mix(color2.color, 1 - weight, { space: encodeSpaceForColorJs(this.space), hue: hueInterpolationMethod, - // @TODO Waiting on new release of ColorJS to fix option types. - // Fixed in: https://github.com/LeaVerou/color.js/pull/347 - } as any); + }); const coords = decodeCoordsFromColorJs(color.coords, this.space === 'rgb'); return new SassColor({ space: this.space, diff --git a/npm/android-arm/README.md b/npm/android-arm/README.md new file mode 100644 index 00000000..c3538822 --- /dev/null +++ b/npm/android-arm/README.md @@ -0,0 +1,3 @@ +# `sass-embedded-android-arm` + +This is the **android-arm** binary for [`sass-embedded`](https://www.npmjs.com/package/sass-embedded) diff --git a/npm/android-arm/package.json b/npm/android-arm/package.json new file mode 100644 index 00000000..9d97fb76 --- /dev/null +++ b/npm/android-arm/package.json @@ -0,0 +1,23 @@ +{ + "name": "sass-embedded-android-arm", + "version": "1.72.0", + "description": "The android-arm binary for sass-embedded", + "repository": "sass/embedded-host-node", + "author": "Google Inc.", + "license": "MIT", + "files": [ + "dart-sass/**/*" + ], + "engines": { + "node": ">=14.0.0" + }, + "bin": { + "sass": "./dart-sass/sass" + }, + "os": [ + "android" + ], + "cpu": [ + "arm" + ] +} diff --git a/npm/android-arm64/README.md b/npm/android-arm64/README.md new file mode 100644 index 00000000..1398e4a3 --- /dev/null +++ b/npm/android-arm64/README.md @@ -0,0 +1,3 @@ +# `sass-embedded-android-arm64` + +This is the **android-arm64** binary for [`sass-embedded`](https://www.npmjs.com/package/sass-embedded) diff --git a/npm/android-arm64/package.json b/npm/android-arm64/package.json new file mode 100644 index 00000000..29b095c8 --- /dev/null +++ b/npm/android-arm64/package.json @@ -0,0 +1,23 @@ +{ + "name": "sass-embedded-android-arm64", + "version": "1.72.0", + "description": "The android-arm64 binary for sass-embedded", + "repository": "sass/embedded-host-node", + "author": "Google Inc.", + "license": "MIT", + "files": [ + "dart-sass/**/*" + ], + "engines": { + "node": ">=14.0.0" + }, + "bin": { + "sass": "./dart-sass/sass" + }, + "os": [ + "android" + ], + "cpu": [ + "arm64" + ] +} diff --git a/npm/android-ia32/README.md b/npm/android-ia32/README.md new file mode 100644 index 00000000..2146454b --- /dev/null +++ b/npm/android-ia32/README.md @@ -0,0 +1,3 @@ +# `sass-embedded-android-ia32` + +This is the **android-ia32** binary for [`sass-embedded`](https://www.npmjs.com/package/sass-embedded) diff --git a/npm/android-ia32/package.json b/npm/android-ia32/package.json new file mode 100644 index 00000000..a8823521 --- /dev/null +++ b/npm/android-ia32/package.json @@ -0,0 +1,23 @@ +{ + "name": "sass-embedded-android-ia32", + "version": "1.72.0", + "description": "The android-ia32 binary for sass-embedded", + "repository": "sass/embedded-host-node", + "author": "Google Inc.", + "license": "MIT", + "files": [ + "dart-sass/**/*" + ], + "engines": { + "node": ">=14.0.0" + }, + "bin": { + "sass": "./dart-sass/sass" + }, + "os": [ + "android" + ], + "cpu": [ + "ia32" + ] +} diff --git a/npm/android-x64/README.md b/npm/android-x64/README.md new file mode 100644 index 00000000..873ab4d7 --- /dev/null +++ b/npm/android-x64/README.md @@ -0,0 +1,3 @@ +# `sass-embedded-android-x64` + +This is the **android-x64** binary for [`sass-embedded`](https://www.npmjs.com/package/sass-embedded) diff --git a/npm/android-x64/package.json b/npm/android-x64/package.json new file mode 100644 index 00000000..997881cd --- /dev/null +++ b/npm/android-x64/package.json @@ -0,0 +1,23 @@ +{ + "name": "sass-embedded-android-x64", + "version": "1.72.0", + "description": "The android-x64 binary for sass-embedded", + "repository": "sass/embedded-host-node", + "author": "Google Inc.", + "license": "MIT", + "files": [ + "dart-sass/**/*" + ], + "engines": { + "node": ">=14.0.0" + }, + "bin": { + "sass": "./dart-sass/sass" + }, + "os": [ + "android" + ], + "cpu": [ + "x64" + ] +} diff --git a/npm/darwin-arm64/package.json b/npm/darwin-arm64/package.json index bffd3b3a..a4e4298f 100644 --- a/npm/darwin-arm64/package.json +++ b/npm/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "sass-embedded-darwin-arm64", - "version": "1.69.5", + "version": "1.72.0", "description": "The darwin-arm64 binary for sass-embedded", "repository": "sass/embedded-host-node", "author": "Google Inc.", diff --git a/npm/darwin-x64/package.json b/npm/darwin-x64/package.json index a18878d4..faa928ea 100644 --- a/npm/darwin-x64/package.json +++ b/npm/darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "sass-embedded-darwin-x64", - "version": "1.69.5", + "version": "1.72.0", "description": "The darwin-x64 binary for sass-embedded", "repository": "sass/embedded-host-node", "author": "Google Inc.", diff --git a/npm/linux-arm/package.json b/npm/linux-arm/package.json index f66dbc1a..ff751c84 100644 --- a/npm/linux-arm/package.json +++ b/npm/linux-arm/package.json @@ -1,6 +1,6 @@ { "name": "sass-embedded-linux-arm", - "version": "1.69.5", + "version": "1.72.0", "description": "The linux-arm binary for sass-embedded", "repository": "sass/embedded-host-node", "author": "Google Inc.", diff --git a/npm/linux-arm64/package.json b/npm/linux-arm64/package.json index d319078e..04bba61c 100644 --- a/npm/linux-arm64/package.json +++ b/npm/linux-arm64/package.json @@ -1,6 +1,6 @@ { "name": "sass-embedded-linux-arm64", - "version": "1.69.5", + "version": "1.72.0", "description": "The linux-arm64 binary for sass-embedded", "repository": "sass/embedded-host-node", "author": "Google Inc.", diff --git a/npm/linux-ia32/package.json b/npm/linux-ia32/package.json index b7181eb6..c1e8a2e4 100644 --- a/npm/linux-ia32/package.json +++ b/npm/linux-ia32/package.json @@ -1,6 +1,6 @@ { "name": "sass-embedded-linux-ia32", - "version": "1.69.5", + "version": "1.72.0", "description": "The linux-ia32 binary for sass-embedded", "repository": "sass/embedded-host-node", "author": "Google Inc.", diff --git a/npm/linux-musl-arm/README.md b/npm/linux-musl-arm/README.md new file mode 100644 index 00000000..df594f68 --- /dev/null +++ b/npm/linux-musl-arm/README.md @@ -0,0 +1,3 @@ +# `sass-embedded-linux-musl-arm` + +This is the **linux-musl-arm** binary for [`sass-embedded`](https://www.npmjs.com/package/sass-embedded) diff --git a/npm/linux-musl-arm/package.json b/npm/linux-musl-arm/package.json new file mode 100644 index 00000000..f258084a --- /dev/null +++ b/npm/linux-musl-arm/package.json @@ -0,0 +1,20 @@ +{ + "name": "sass-embedded-linux-musl-arm", + "version": "1.72.0", + "description": "The linux-musl-arm binary for sass-embedded", + "repository": "sass/embedded-host-node", + "author": "Google Inc.", + "license": "MIT", + "files": [ + "dart-sass/**/*" + ], + "engines": { + "node": ">=14.0.0" + }, + "os": [ + "linux" + ], + "cpu": [ + "arm" + ] +} diff --git a/npm/linux-musl-arm64/README.md b/npm/linux-musl-arm64/README.md new file mode 100644 index 00000000..a1f1c6df --- /dev/null +++ b/npm/linux-musl-arm64/README.md @@ -0,0 +1,3 @@ +# `sass-embedded-linux-musl-arm64` + +This is the **linux-musl-arm64** binary for [`sass-embedded`](https://www.npmjs.com/package/sass-embedded) diff --git a/npm/linux-musl-arm64/package.json b/npm/linux-musl-arm64/package.json new file mode 100644 index 00000000..f80dce2e --- /dev/null +++ b/npm/linux-musl-arm64/package.json @@ -0,0 +1,20 @@ +{ + "name": "sass-embedded-linux-musl-arm64", + "version": "1.72.0", + "description": "The linux-musl-arm64 binary for sass-embedded", + "repository": "sass/embedded-host-node", + "author": "Google Inc.", + "license": "MIT", + "files": [ + "dart-sass/**/*" + ], + "engines": { + "node": ">=14.0.0" + }, + "os": [ + "linux" + ], + "cpu": [ + "arm64" + ] +} diff --git a/npm/linux-musl-ia32/README.md b/npm/linux-musl-ia32/README.md new file mode 100644 index 00000000..918fff76 --- /dev/null +++ b/npm/linux-musl-ia32/README.md @@ -0,0 +1,3 @@ +# `sass-embedded-linux-musl-ia32` + +This is the **linux-musl-ia32** binary for [`sass-embedded`](https://www.npmjs.com/package/sass-embedded) diff --git a/npm/linux-musl-ia32/package.json b/npm/linux-musl-ia32/package.json new file mode 100644 index 00000000..2516f230 --- /dev/null +++ b/npm/linux-musl-ia32/package.json @@ -0,0 +1,20 @@ +{ + "name": "sass-embedded-linux-musl-ia32", + "version": "1.72.0", + "description": "The linux-musl-ia32 binary for sass-embedded", + "repository": "sass/embedded-host-node", + "author": "Google Inc.", + "license": "MIT", + "files": [ + "dart-sass/**/*" + ], + "engines": { + "node": ">=14.0.0" + }, + "os": [ + "linux" + ], + "cpu": [ + "ia32" + ] +} diff --git a/npm/linux-musl-x64/README.md b/npm/linux-musl-x64/README.md new file mode 100644 index 00000000..476f09ec --- /dev/null +++ b/npm/linux-musl-x64/README.md @@ -0,0 +1,3 @@ +# `sass-embedded-linux-musl-x64` + +This is the **linux-musl-x64** binary for [`sass-embedded`](https://www.npmjs.com/package/sass-embedded) diff --git a/npm/linux-musl-x64/package.json b/npm/linux-musl-x64/package.json new file mode 100644 index 00000000..9641c200 --- /dev/null +++ b/npm/linux-musl-x64/package.json @@ -0,0 +1,20 @@ +{ + "name": "sass-embedded-linux-musl-x64", + "version": "1.72.0", + "description": "The linux-musl-x64 binary for sass-embedded", + "repository": "sass/embedded-host-node", + "author": "Google Inc.", + "license": "MIT", + "files": [ + "dart-sass/**/*" + ], + "engines": { + "node": ">=14.0.0" + }, + "os": [ + "linux" + ], + "cpu": [ + "x64" + ] +} diff --git a/npm/linux-x64/package.json b/npm/linux-x64/package.json index 5896693e..423900c7 100644 --- a/npm/linux-x64/package.json +++ b/npm/linux-x64/package.json @@ -1,6 +1,6 @@ { "name": "sass-embedded-linux-x64", - "version": "1.69.5", + "version": "1.72.0", "description": "The linux-x64 binary for sass-embedded", "repository": "sass/embedded-host-node", "author": "Google Inc.", diff --git a/npm/win32-arm64/README.md b/npm/win32-arm64/README.md new file mode 100644 index 00000000..a13f4bc5 --- /dev/null +++ b/npm/win32-arm64/README.md @@ -0,0 +1,3 @@ +# `sass-embedded-win32-arm64` + +This is the **win32-arm64** binary for [`sass-embedded`](https://www.npmjs.com/package/sass-embedded) diff --git a/npm/win32-arm64/package.json b/npm/win32-arm64/package.json new file mode 100644 index 00000000..9bc7bdc2 --- /dev/null +++ b/npm/win32-arm64/package.json @@ -0,0 +1,23 @@ +{ + "name": "sass-embedded-win32-arm64", + "version": "1.72.0", + "description": "The win32-arm64 binary for sass-embedded", + "repository": "sass/embedded-host-node", + "author": "Google Inc.", + "license": "MIT", + "files": [ + "dart-sass/**/*" + ], + "engines": { + "node": ">=14.0.0" + }, + "bin": { + "sass": "./dart-sass/sass.bat" + }, + "os": [ + "win32" + ], + "cpu": [ + "arm64" + ] +} diff --git a/npm/win32-ia32/package.json b/npm/win32-ia32/package.json index 5bfdf49a..4ce2dbc2 100644 --- a/npm/win32-ia32/package.json +++ b/npm/win32-ia32/package.json @@ -1,6 +1,6 @@ { "name": "sass-embedded-win32-ia32", - "version": "1.69.5", + "version": "1.72.0", "description": "The win32-ia32 binary for sass-embedded", "repository": "sass/embedded-host-node", "author": "Google Inc.", diff --git a/npm/win32-x64/package.json b/npm/win32-x64/package.json index 67fbe37c..66788151 100644 --- a/npm/win32-x64/package.json +++ b/npm/win32-x64/package.json @@ -1,6 +1,6 @@ { "name": "sass-embedded-win32-x64", - "version": "1.69.5", + "version": "1.72.0", "description": "The win32-x64 binary for sass-embedded", "repository": "sass/embedded-host-node", "author": "Google Inc.", diff --git a/package.json b/package.json index f0edc305..a4f62a4f 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "sass-embedded", - "version": "1.69.5", + "version": "1.72.0", "protocol-version": "3.0.0-dev", - "compiler-version": "1.69.5", + "compiler-version": "1.72.0", "description": "Node.js library that communicates with Embedded Dart Sass using the Embedded Sass protocol", "repository": "sass/embedded-host-node", "author": "Google Inc.", @@ -35,19 +35,28 @@ "test": "jest" }, "optionalDependencies": { - "sass-embedded-darwin-arm64": "1.69.5", - "sass-embedded-darwin-x64": "1.69.5", - "sass-embedded-linux-arm": "1.69.5", - "sass-embedded-linux-arm64": "1.69.5", - "sass-embedded-linux-ia32": "1.69.5", - "sass-embedded-linux-x64": "1.69.5", - "sass-embedded-win32-ia32": "1.69.5", - "sass-embedded-win32-x64": "1.69.5" + "sass-embedded-android-arm": "1.72.0", + "sass-embedded-android-arm64": "1.72.0", + "sass-embedded-android-ia32": "1.72.0", + "sass-embedded-android-x64": "1.72.0", + "sass-embedded-darwin-arm64": "1.72.0", + "sass-embedded-darwin-x64": "1.72.0", + "sass-embedded-linux-arm": "1.72.0", + "sass-embedded-linux-arm64": "1.72.0", + "sass-embedded-linux-ia32": "1.72.0", + "sass-embedded-linux-x64": "1.72.0", + "sass-embedded-linux-musl-arm": "1.72.0", + "sass-embedded-linux-musl-arm64": "1.72.0", + "sass-embedded-linux-musl-ia32": "1.72.0", + "sass-embedded-linux-musl-x64": "1.72.0", + "sass-embedded-win32-arm64": "1.72.0", + "sass-embedded-win32-ia32": "1.72.0", + "sass-embedded-win32-x64": "1.72.0" }, "dependencies": { "@bufbuild/protobuf": "^1.0.0", "buffer-builder": "^0.2.0", - "colorjs.io": "^0.4.5", + "colorjs.io": "^0.5.0", "immutable": "^4.0.0", "rxjs": "^7.4.0", "supports-color": "^8.1.1", diff --git a/tool/prepare-optional-release.ts b/tool/prepare-optional-release.ts index 36a42ba7..c0e606c3 100644 --- a/tool/prepare-optional-release.ts +++ b/tool/prepare-optional-release.ts @@ -7,7 +7,12 @@ import yargs from 'yargs'; import * as pkg from '../package.json'; import * as utils from './utils'; -export type DartPlatform = 'linux' | 'macos' | 'windows'; +export type DartPlatform = + | 'android' + | 'linux' + | 'linux-musl' + | 'macos' + | 'windows'; export type DartArch = 'ia32' | 'x64' | 'arm' | 'arm64'; const argv = yargs(process.argv.slice(2)) @@ -27,7 +32,10 @@ const argv = yargs(process.argv.slice(2)) // Dart Sass Embedded. export function nodePlatformToDartPlatform(platform: string): DartPlatform { switch (platform) { + case 'android': + return 'android'; case 'linux': + case 'linux-musl': return 'linux'; case 'darwin': return 'macos'; @@ -104,6 +112,47 @@ async function downloadRelease(options: { await fs.unlink(zippedAssetPath); } +// Patch the launcher script if needed. +// +// For linux both `-linux-` and `-linux-musl-` packages will be installed +// because npm doesn't know how to select packages based on LibC. To avoid +// conflicts, only the `-linux-` packages have "bin" scripts defined in +// package.json, which we patch to detect which LibC is available and launch the +// correct binary. +async function patchLauncherScript( + path: string, + dartPlatform: DartPlatform, + dartArch: DartArch +) { + if (dartPlatform !== 'linux') return; + + const scriptPath = p.join(path, 'dart-sass', 'sass'); + console.log(`Patching ${scriptPath} script.`); + + const shebang = Buffer.from('#!/bin/sh\n'); + const buffer = await fs.readFile(scriptPath); + if (!buffer.subarray(0, shebang.length).equals(shebang)) { + throw new Error(`${scriptPath} is not a shell script!`); + } + + const lines = buffer.toString('utf-8').split('\n'); + const index = lines.findIndex(line => line.startsWith('path=')); + if (index < 0) { + throw new Error(`The format of ${scriptPath} has changed!`); + } + + lines.splice( + index + 1, + 0, + '# Detect linux-musl', + 'if grep -qm 1 /ld-musl- /proc/self/exe; then', + ` path="$path/../../sass-embedded-linux-musl-${dartArch}/dart-sass"`, + 'fi' + ); + + await fs.writeFile(scriptPath, lines.join('\n')); +} + void (async () => { try { const version = pkg['compiler-version'] as string; @@ -113,18 +162,23 @@ void (async () => { ); } - const [nodePlatform, nodeArch] = argv.package.split('-'); + const index = argv.package.lastIndexOf('-'); + const nodePlatform = argv.package.substring(0, index); + const nodeArch = argv.package.substring(index + 1); const dartPlatform = nodePlatformToDartPlatform(nodePlatform); const dartArch = nodeArchToDartArch(nodeArch); + const isMusl = nodePlatform === 'linux-musl'; const outPath = p.join('npm', argv.package); await downloadRelease({ repo: 'dart-sass', assetUrl: 'https://github.com/sass/dart-sass/releases/download/' + `${version}/dart-sass-${version}-` + - `${dartPlatform}-${dartArch}${getArchiveExtension(dartPlatform)}`, + `${dartPlatform}-${dartArch}${isMusl ? '-musl' : ''}` + + `${getArchiveExtension(dartPlatform)}`, outPath, }); + await patchLauncherScript(outPath, dartPlatform, dartArch); } catch (error) { console.error(error); process.exitCode = 1; diff --git a/tool/utils.ts b/tool/utils.ts index 4762fad6..1a9e9ae1 100644 --- a/tool/utils.ts +++ b/tool/utils.ts @@ -68,7 +68,7 @@ export async function cleanDir(dir: string): Promise { } } -/// Returns whether [path1] and [path2] are symlinks that refer to the same file. +// Returns whether [path1] and [path2] are symlinks that refer to the same file. export async function sameTarget( path1: string, path2: string @@ -79,7 +79,7 @@ export async function sameTarget( return realpath1 === (await tryRealpath(path2)); } -/// Like `fs.realpath()`, but returns `null` if the path doesn't exist on disk. +// Like `fs.realpath()`, but returns `null` if the path doesn't exist on disk. async function tryRealpath(path: string): Promise { try { return await fs.realpath(p.resolve(path));