diff --git a/lib/src/compiler-path.ts b/lib/src/compiler-path.ts index 0097ac28..4b9b2e8a 100644 --- a/lib/src/compiler-path.ts +++ b/lib/src/compiler-path.ts @@ -53,8 +53,10 @@ export const compilerCommand = (() => { `sass-embedded-${platform}-${arch}/dart-sass/src/sass.snapshot` ), ]; - } catch (ignored) { - // ignored + } catch (e) { + if (!(isErrnoException(e) && e.code === 'MODULE_NOT_FOUND')) { + throw e; + } } try { @@ -70,10 +72,21 @@ export const compilerCommand = (() => { } } + try { + return [ + process.execPath, + p.join(p.dirname(require.resolve('sass')), 'sass.js'), + ]; + } catch (e: unknown) { + if (!(isErrnoException(e) && e.code === 'MODULE_NOT_FOUND')) { + throw e; + } + } + throw new Error( "Embedded Dart Sass couldn't find the embedded compiler executable. " + 'Please make sure the optional dependency ' + - `sass-embedded-${platform}-${arch} is installed in ` + + `sass-embedded-${platform}-${arch} or sass is installed in ` + 'node_modules.' ); })(); diff --git a/lib/src/embedded/index.mjs b/lib/src/embedded/index.mjs new file mode 100644 index 00000000..845c570f --- /dev/null +++ b/lib/src/embedded/index.mjs @@ -0,0 +1,7 @@ +// 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 embedded from './index.js'; + +export const main = embedded.main; diff --git a/lib/src/embedded/index.ts b/lib/src/embedded/index.ts new file mode 100644 index 00000000..af1e7683 --- /dev/null +++ b/lib/src/embedded/index.ts @@ -0,0 +1,50 @@ +// 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 {MessagePort, isMainThread, workerData} from 'worker_threads'; +import {toJson} from '@bufbuild/protobuf'; + +import {SyncMessagePort} from '../sync-process/sync-message-port'; +import {WorkerDispatcher} from './worker_dispatcher'; +import * as proto from '../vendor/embedded_sass_pb'; + +export function main( + spawnCompilationDispatcher: ( + mailbox: SyncMessagePort, + sendPort: MessagePort + ) => void +): void { + if (isMainThread) { + if (process.argv.length > 3) { + if (process.argv[3] === '--version') { + console.log( + JSON.stringify( + toJson( + proto.OutboundMessage_VersionResponseSchema, + WorkerDispatcher.versionResponse() + ), + null, + 2 + ) + ); + } else { + console.error( + 'sass --embedded is not intended to be executed with additional arguments.\n' + + 'See https://github.com/sass/dart-sass#embedded-dart-sass for details.' + ); + process.exitCode = 64; + } + return; + } + + new WorkerDispatcher().listen(); + } else { + const port = workerData.port as MessagePort; + spawnCompilationDispatcher(new SyncMessagePort(port), { + postMessage(buffer: Uint8Array): void { + port.postMessage(buffer, [buffer.buffer]); + }, + } as MessagePort); + } +} diff --git a/lib/src/embedded/reusable_worker.ts b/lib/src/embedded/reusable_worker.ts new file mode 100644 index 00000000..6ab3b4aa --- /dev/null +++ b/lib/src/embedded/reusable_worker.ts @@ -0,0 +1,60 @@ +// 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 {MessagePort, Worker} from 'worker_threads'; + +import {SyncMessagePort} from '../sync-process/sync-message-port'; + +export class ReusableWorker { + private readonly worker: Worker; + + private readonly receivePort: MessagePort; + + private readonly sendPort: SyncMessagePort; + + private onMessage = this.defaultOnMessage; + + constructor(path: string) { + const {port1, port2} = SyncMessagePort.createChannel(); + this.worker = new Worker(path, { + workerData: {port: port2}, + transferList: [port2], + argv: process.argv.slice(2), + }); + this.receivePort = port1; + this.sendPort = new SyncMessagePort(port1); + + this.receivePort.on('message', value => this.onMessage(value)); + } + + borrow(listener: (value: Uint8Array) => void): void { + if (this.onMessage !== this.defaultOnMessage) { + throw new Error('ReusableWorker has already been borrowed.'); + } + this.onMessage = listener; + } + + release(): void { + if (this.onMessage === this.defaultOnMessage) { + throw new Error('ReusableWorker has not been borrowed.'); + } + this.onMessage = this.defaultOnMessage; + } + + send(value: Uint8Array): void { + this.sendPort.postMessage(value, [value.buffer]); + } + + terminate(): void { + this.sendPort.close(); + this.worker.terminate(); + this.receivePort.close(); + } + + private defaultOnMessage(value: Uint8Array): void { + throw new Error( + `Shouldn't receive a message before being borrowed: ${value}.` + ); + } +} diff --git a/lib/src/embedded/utils.ts b/lib/src/embedded/utils.ts new file mode 100644 index 00000000..283d1fd3 --- /dev/null +++ b/lib/src/embedded/utils.ts @@ -0,0 +1,50 @@ +// 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 {create} from '@bufbuild/protobuf'; + +import * as proto from '../vendor/embedded_sass_pb'; + +export const errorId = 0xffffffff; + +export function paramsError(message: string): proto.ProtocolError { + return create(proto.ProtocolErrorSchema, { + id: errorId, + type: proto.ProtocolErrorType.PARAMS, + message: message, + }); +} + +export function parseError(message: string): proto.ProtocolError { + return create(proto.ProtocolErrorSchema, { + type: proto.ProtocolErrorType.PARSE, + message: message, + }); +} + +export function handleError( + error: Error | proto.ProtocolError, + {messageId}: {messageId?: number} = {} +): proto.ProtocolError { + if (error instanceof Error) { + const errorMessage = `${error.message}\n${error.stack}`; + process.stderr.write(`Internal compiler error: ${errorMessage}`); + process.exitCode = 70; // EX_SOFTWARE + return create(proto.ProtocolErrorSchema, { + id: messageId ?? errorId, + type: proto.ProtocolErrorType.INTERNAL, + message: errorMessage, + }); + } else { + error.id = messageId ?? errorId; + process.stderr.write( + `Host caused ${proto.ProtocolErrorType[error.type].toLowerCase()} error` + ); + if (error.id !== errorId) process.stderr.write(` with request ${error.id}`); + process.stderr.write(`: ${error.message}\n`); + // PROTOCOL error from https://bit.ly/2poTt90 + process.exitCode = 76; // EX_PROTOCOL + return error; + } +} diff --git a/lib/src/embedded/worker_dispatcher.ts b/lib/src/embedded/worker_dispatcher.ts new file mode 100644 index 00000000..65cbe6d2 --- /dev/null +++ b/lib/src/embedded/worker_dispatcher.ts @@ -0,0 +1,175 @@ +// 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 {Observable} from 'rxjs'; +import {takeUntil} from 'rxjs/operators'; +import {create, fromBinary, toBinary} from '@bufbuild/protobuf'; +import * as varint from 'varint'; + +import * as pkg from '../../../package.json'; +import {PacketTransformer} from '../packet-transformer'; +import {ReusableWorker} from './reusable_worker'; +import {errorId, handleError, paramsError, parseError} from './utils'; +import * as proto from '../vendor/embedded_sass_pb'; + +export class WorkerDispatcher { + private readonly allWorkers: ReusableWorker[] = []; + + private readonly inactiveWorkers: ReusableWorker[] = []; + + private readonly activeWorkers = new Map(); + + private readonly stdin$ = new Observable(observer => { + process.stdin.on('data', buffer => observer.next(buffer)); + }).pipe( + takeUntil( + new Promise(resolve => { + process.stdin.on('close', () => resolve(undefined)); + }) + ) + ); + + private readonly packetTransformer = new PacketTransformer( + this.stdin$, + buffer => process.stdout.write(buffer) + ); + + listen(): void { + this.packetTransformer.protobufs$.subscribe({ + next: (buffer: Uint8Array) => { + let compilationId: number; + try { + compilationId = varint.decode(buffer); + } catch (error) { + throw parseError(`Invalid compilation ID varint: ${error}`); + } + + try { + if (compilationId !== 0) { + if (this.activeWorkers.has(compilationId)) { + const worker = this.activeWorkers.get(compilationId)!; + worker.send(buffer); + } else { + const worker = this.getWorker(compilationId); + this.activeWorkers.set(compilationId, worker); + worker.send(buffer); + } + return; + } + + let message; + try { + message = fromBinary( + proto.InboundMessageSchema, + new Uint8Array(buffer.buffer, varint.decode.bytes) + ); + } catch (error) { + throw parseError(`Invalid protobuf: ${error}`); + } + + if (message.message.case !== 'versionRequest') { + throw paramsError( + `Only VersionRequest may have wire ID 0, was ${message.message.case}.` + ); + } + const request = message.message.value; + const response = WorkerDispatcher.versionResponse(); + response.id = request.id; + this.send( + 0, + create(proto.OutboundMessageSchema, { + message: { + case: 'versionResponse', + value: response, + }, + }) + ); + } catch (error) { + this.handleError(error); + } + }, + complete: () => { + this.allWorkers.forEach(worker => worker.terminate()); + }, + error: error => { + this.handleError(parseError(error.message)); + }, + }); + } + + private getWorker(compilationId: number): ReusableWorker { + let worker: ReusableWorker; + if (this.inactiveWorkers.length > 0) { + worker = this.inactiveWorkers.pop()!; + } else { + worker = new ReusableWorker(process.argv[1]); + this.allWorkers.push(worker); + } + + worker.borrow(buffer => { + const category = buffer.at(0); + const packet = Buffer.from(buffer.buffer, 1); + + switch (category) { + case 0: + this.packetTransformer.writeProtobuf(packet); + break; + case 1: + this.activeWorkers.delete(compilationId); + worker.release(); + this.inactiveWorkers.push(worker); + this.packetTransformer.writeProtobuf(packet); + break; + case 2: { + this.packetTransformer.writeProtobuf(packet); + /* eslint-disable-next-line n/no-process-exit */ + process.exit(); + } + } + }); + + return worker; + } + + private handleError( + error: Error | proto.ProtocolError, + { + compilationId, + messageId, + }: {compilationId?: number; messageId?: number} = {} + ): void { + this.sendError(compilationId ?? errorId, handleError(error, {messageId})); + process.stdin.destroy(); + } + + private send(compilationId: number, message: proto.OutboundMessage): void { + const compilationIdLength = varint.encodingLength(compilationId); + const encodedMessage = toBinary(proto.OutboundMessageSchema, message); + const buffer = new Uint8Array(compilationIdLength + encodedMessage.length); + varint.encode(compilationId, buffer); + buffer.set(encodedMessage, compilationIdLength); + this.packetTransformer.writeProtobuf(buffer); + } + + private sendError(compilationId: number, error: proto.ProtocolError): void { + this.send( + compilationId, + create(proto.OutboundMessageSchema, { + message: { + case: 'error', + value: error, + }, + }) + ); + } + + static versionResponse(): proto.OutboundMessage_VersionResponse { + return create(proto.OutboundMessage_VersionResponseSchema, { + protocolVersion: pkg['protocol-version'], + compilerVersion: pkg['compiler-version'], + implementationVersion: pkg['version'], + implementationName: 'dart-sass', + }); + } +} diff --git a/lib/src/message-transformer.ts b/lib/src/message-transformer.ts index e1950d9f..d71fe160 100644 --- a/lib/src/message-transformer.ts +++ b/lib/src/message-transformer.ts @@ -101,24 +101,3 @@ export class HostMessageTransformer extends MessageTransformer< ); } } - -/** - * Encodes OutboundMessage into protocol buffers and decodes protocol buffers - * into InboundMessage. - */ -export class CompilerMessageTransformer extends MessageTransformer< - proto.InboundMessage, - proto.OutboundMessage -> { - constructor( - protobufs$: Observable, - writeProtobuf: (buffer: Uint8Array) => void - ) { - super( - proto.InboundMessageSchema, - proto.OutboundMessageSchema, - protobufs$, - writeProtobuf - ); - } -} diff --git a/package.json b/package.json index 90b61276..5eddec08 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "check:gts": "gts check", "check:tsc": "tsc --noEmit", "clean": "gts clean", - "compile": "tsc -p tsconfig.build.json && cp lib/index.mjs dist/lib/index.mjs && cp -r lib/src/vendor/sass/ dist/lib/src/vendor/sass && cp dist/lib/src/vendor/sass/index.d.ts dist/lib/src/vendor/sass/index.m.d.ts", + "compile": "tsc -p tsconfig.build.json && cp lib/index.mjs dist/lib/index.mjs && cp lib/src/embedded/index.mjs dist/lib/src/embedded/index.mjs && cp -r lib/src/vendor/sass/ dist/lib/src/vendor/sass && cp dist/lib/src/vendor/sass/index.d.ts dist/lib/src/vendor/sass/index.m.d.ts", "fix": "gts fix", "prepublishOnly": "npm run clean && ts-node ./tool/prepare-release.ts", "test": "jest"