6
6
* found in the LICENSE file at https://angular.io/license
7
7
*/
8
8
9
- import { join } from 'node:path ' ;
9
+ import assert from 'node:assert ' ;
10
10
import { fileURLToPath , pathToFileURL } from 'node:url' ;
11
- import { MessageChannel , Worker } from 'node:worker_threads' ;
12
- import {
11
+ import { MessageChannel } from 'node:worker_threads' ;
12
+ import { Piscina } from 'piscina' ;
13
+ import type {
13
14
CanonicalizeContext ,
14
15
CompileResult ,
15
16
Deprecation ,
16
17
Exception ,
17
18
FileImporter ,
18
19
Importer ,
19
- Logger ,
20
20
NodePackageImporter ,
21
21
SourceSpan ,
22
22
StringOptions ,
23
23
} from 'sass' ;
24
24
import { maxWorkers } from '../../utils/environment-options' ;
25
25
26
+ // Polyfill Symbol.dispose if not present
27
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
28
+ ( Symbol as any ) . dispose ??= Symbol ( 'Symbol Dispose' ) ;
29
+
26
30
/**
27
31
* The maximum number of Workers that will be created to execute render requests.
28
32
*/
29
33
const MAX_RENDER_WORKERS = maxWorkers ;
30
34
31
- /**
32
- * The callback type for the `dart-sass` asynchronous render function.
33
- */
34
- type RenderCallback = ( error ?: Exception , result ?: CompileResult ) => void ;
35
-
36
- /**
37
- * An object containing the contextual information for a specific render request.
38
- */
39
- interface RenderRequest {
40
- id : number ;
41
- workerIndex : number ;
42
- callback : RenderCallback ;
43
- logger ?: Logger ;
44
- importers ?: Importers [ ] ;
45
- }
46
-
47
35
/**
48
36
* All available importer types.
49
37
*/
@@ -83,7 +71,6 @@ export type SerializableWarningMessage = (
83
71
* A response from the Sass render Worker containing the result of the operation.
84
72
*/
85
73
interface RenderResponseMessage {
86
- id : number ;
87
74
error ?: Exception ;
88
75
result ?: Omit < CompileResult , 'loadedUrls' > & { loadedUrls : string [ ] } ;
89
76
warnings ?: SerializableWarningMessage [ ] ;
@@ -96,14 +83,25 @@ interface RenderResponseMessage {
96
83
* the worker which can be up to two times faster than the asynchronous variant.
97
84
*/
98
85
export class SassWorkerImplementation {
99
- private readonly workers : Worker [ ] = [ ] ;
100
- private readonly availableWorkers : number [ ] = [ ] ;
101
- private readonly requests = new Map < number , RenderRequest > ( ) ;
102
- private readonly workerPath = join ( __dirname , './worker.js' ) ;
103
- private idCounter = 1 ;
104
- private nextWorkerIndex = 0 ;
86
+ #workerPool: Piscina | undefined ;
87
+
88
+ constructor (
89
+ private readonly rebase = false ,
90
+ readonly maxThreads = MAX_RENDER_WORKERS ,
91
+ ) { }
92
+
93
+ #ensureWorkerPool( ) : Piscina {
94
+ this . #workerPool ??= new Piscina ( {
95
+ filename : require . resolve ( './worker' ) ,
96
+ minThreads : 1 ,
97
+ maxThreads : this . maxThreads ,
98
+ // Shutdown idle threads after 1 second of inactivity
99
+ idleTimeout : 1000 ,
100
+ recordTiming : false ,
101
+ } ) ;
105
102
106
- constructor ( private rebase = false ) { }
103
+ return this . #workerPool;
104
+ }
107
105
108
106
/**
109
107
* Provides information about the Sass implementation.
@@ -126,136 +124,94 @@ export class SassWorkerImplementation {
126
124
* @param source The contents to compile.
127
125
* @param options The `dart-sass` options to use when rendering the stylesheet.
128
126
*/
129
- compileStringAsync ( source : string , options : StringOptions < 'async' > ) : Promise < CompileResult > {
127
+ async compileStringAsync (
128
+ source : string ,
129
+ options : StringOptions < 'async' > ,
130
+ ) : Promise < CompileResult > {
130
131
// The `functions`, `logger` and `importer` options are JavaScript functions that cannot be transferred.
131
132
// If any additional function options are added in the future, they must be excluded as well.
132
133
const { functions, importers, url, logger, ...serializableOptions } = options ;
133
134
134
- // The CLI's configuration does not use or expose the ability to defined custom Sass functions
135
+ // The CLI's configuration does not use or expose the ability to define custom Sass functions
135
136
if ( functions && Object . keys ( functions ) . length > 0 ) {
136
137
throw new Error ( 'Sass custom functions are not supported.' ) ;
137
138
}
138
139
139
- return new Promise < CompileResult > ( ( resolve , reject ) => {
140
- let workerIndex = this . availableWorkers . pop ( ) ;
141
- if ( workerIndex === undefined ) {
142
- if ( this . workers . length < MAX_RENDER_WORKERS ) {
143
- workerIndex = this . workers . length ;
144
- this . workers . push ( this . createWorker ( ) ) ;
145
- } else {
146
- workerIndex = this . nextWorkerIndex ++ ;
147
- if ( this . nextWorkerIndex >= this . workers . length ) {
148
- this . nextWorkerIndex = 0 ;
149
- }
150
- }
151
- }
152
-
153
- const callback : RenderCallback = ( error , result ) => {
154
- if ( error ) {
155
- const url = error . span ?. url as string | undefined ;
156
- if ( url ) {
157
- error . span . url = pathToFileURL ( url ) ;
158
- }
159
-
160
- reject ( error ) ;
161
-
162
- return ;
163
- }
164
-
165
- if ( ! result ) {
166
- reject ( new Error ( 'No result.' ) ) ;
140
+ using importerChannel = importers ?. length ? this . #createImporterChannel( importers ) : undefined ;
167
141
168
- return ;
169
- }
170
-
171
- resolve ( result ) ;
172
- } ;
173
-
174
- const request = this . createRequest ( workerIndex , callback , logger , importers ) ;
175
- this . requests . set ( request . id , request ) ;
176
-
177
- this . workers [ workerIndex ] . postMessage ( {
178
- id : request . id ,
142
+ const response = ( await this . #ensureWorkerPool( ) . run (
143
+ {
179
144
source,
180
- hasImporter : ! ! importers ?. length ,
145
+ importerChannel ,
181
146
hasLogger : ! ! logger ,
182
147
rebase : this . rebase ,
183
148
options : {
184
149
...serializableOptions ,
185
150
// URL is not serializable so to convert to string here and back to URL in the worker.
186
151
url : url ? fileURLToPath ( url ) : undefined ,
187
152
} ,
188
- } ) ;
189
- } ) ;
153
+ } ,
154
+ {
155
+ transferList : importerChannel ? [ importerChannel . port ] : undefined ,
156
+ } ,
157
+ ) ) as RenderResponseMessage ;
158
+
159
+ const { result, error, warnings } = response ;
160
+
161
+ if ( warnings && logger ?. warn ) {
162
+ for ( const { message, span, ...options } of warnings ) {
163
+ logger . warn ( message , {
164
+ ...options ,
165
+ span : span && {
166
+ ...span ,
167
+ url : span . url ? pathToFileURL ( span . url ) : undefined ,
168
+ } ,
169
+ } ) ;
170
+ }
171
+ }
172
+
173
+ if ( error ) {
174
+ // Convert stringified url value required for cloning back to a URL object
175
+ const url = error . span ?. url as string | undefined ;
176
+ if ( url ) {
177
+ error . span . url = pathToFileURL ( url ) ;
178
+ }
179
+
180
+ throw error ;
181
+ }
182
+
183
+ assert ( result , 'Sass render worker should always return a result or an error' ) ;
184
+
185
+ return {
186
+ ...result ,
187
+ // URL is not serializable so in the worker we convert to string and here back to URL.
188
+ loadedUrls : result . loadedUrls . map ( ( p ) => pathToFileURL ( p ) ) ,
189
+ } ;
190
190
}
191
191
192
192
/**
193
193
* Shutdown the Sass render worker.
194
194
* Executing this method will stop any pending render requests.
195
+ * @returns A void promise that resolves when closing is complete.
195
196
*/
196
- close ( ) : void {
197
- for ( const worker of this . workers ) {
197
+ async close ( ) : Promise < void > {
198
+ if ( this . #workerPool ) {
198
199
try {
199
- void worker . terminate ( ) ;
200
- } catch { }
200
+ await this . #workerPool. destroy ( ) ;
201
+ } finally {
202
+ this . #workerPool = undefined ;
203
+ }
201
204
}
202
- this . requests . clear ( ) ;
203
205
}
204
206
205
- private createWorker ( ) : Worker {
207
+ #createImporterChannel ( importers : Iterable < Importers > ) {
206
208
const { port1 : mainImporterPort , port2 : workerImporterPort } = new MessageChannel ( ) ;
207
209
const importerSignal = new Int32Array ( new SharedArrayBuffer ( 4 ) ) ;
208
210
209
- const worker = new Worker ( this . workerPath , {
210
- workerData : { workerImporterPort, importerSignal } ,
211
- transferList : [ workerImporterPort ] ,
212
- } ) ;
213
-
214
- worker . on ( 'message' , ( response : RenderResponseMessage ) => {
215
- const request = this . requests . get ( response . id ) ;
216
- if ( ! request ) {
217
- return ;
218
- }
219
-
220
- this . requests . delete ( response . id ) ;
221
- this . availableWorkers . push ( request . workerIndex ) ;
222
-
223
- if ( response . warnings && request . logger ?. warn ) {
224
- for ( const { message, span, ...options } of response . warnings ) {
225
- request . logger . warn ( message , {
226
- ...options ,
227
- span : span && {
228
- ...span ,
229
- url : span . url ? pathToFileURL ( span . url ) : undefined ,
230
- } ,
231
- } ) ;
232
- }
233
- }
234
-
235
- if ( response . result ) {
236
- request . callback ( undefined , {
237
- ...response . result ,
238
- // URL is not serializable so in the worker we convert to string and here back to URL.
239
- loadedUrls : response . result . loadedUrls . map ( ( p ) => pathToFileURL ( p ) ) ,
240
- } ) ;
241
- } else {
242
- request . callback ( response . error ) ;
243
- }
244
- } ) ;
245
-
246
211
mainImporterPort . on (
247
212
'message' ,
248
- ( { id, url, options } : { id : number ; url : string ; options : CanonicalizeContext } ) => {
249
- const request = this . requests . get ( id ) ;
250
- if ( ! request ?. importers ) {
251
- mainImporterPort . postMessage ( null ) ;
252
- Atomics . store ( importerSignal , 0 , 1 ) ;
253
- Atomics . notify ( importerSignal , 0 ) ;
254
-
255
- return ;
256
- }
257
-
258
- this . processImporters ( request . importers , url , {
213
+ ( { url, options } : { url : string ; options : CanonicalizeContext } ) => {
214
+ this . processImporters ( importers , url , {
259
215
...options ,
260
216
// URL is not serializable so in the worker we convert to string and here back to URL.
261
217
containingUrl : options . containingUrl
@@ -277,7 +233,13 @@ export class SassWorkerImplementation {
277
233
278
234
mainImporterPort . unref ( ) ;
279
235
280
- return worker ;
236
+ return {
237
+ port : workerImporterPort ,
238
+ signal : importerSignal ,
239
+ [ Symbol . dispose ] ( ) {
240
+ mainImporterPort . close ( ) ;
241
+ } ,
242
+ } ;
281
243
}
282
244
283
245
private async processImporters (
@@ -301,21 +263,6 @@ export class SassWorkerImplementation {
301
263
return null ;
302
264
}
303
265
304
- private createRequest (
305
- workerIndex : number ,
306
- callback : RenderCallback ,
307
- logger : Logger | undefined ,
308
- importers : Importers [ ] | undefined ,
309
- ) : RenderRequest {
310
- return {
311
- id : this . idCounter ++ ,
312
- workerIndex,
313
- callback,
314
- logger,
315
- importers,
316
- } ;
317
- }
318
-
319
266
private isFileImporter ( value : Importers ) : value is FileImporter {
320
267
return 'findFileUrl' in value ;
321
268
}
0 commit comments