Skip to content
This repository was archived by the owner on Jan 20, 2022. It is now read-only.

Commit 094466d

Browse files
committed
feat(links): add lifecycle scripts interdependency
Adds the ability for lifecycle scripts of Link nodes to depend on each other, such that a `prepare` script of linked module A can make use of files that are a result of a `prepare` script of a linked package B. This is important in order to unlock workflows in which a workspace needs to make use of another workspace but they all have transpilation steps (very common in Typescript projects) so tracking the order (and completion) in which the dependencies lifecycle scripts runs becomes necessary. Fixes: npm/cli#3034
1 parent b3cc0d7 commit 094466d

File tree

2 files changed

+255
-57
lines changed

2 files changed

+255
-57
lines changed

lib/arborist/rebuild.js

+146-57
Original file line numberDiff line numberDiff line change
@@ -249,73 +249,162 @@ module.exports = cls => class Builder extends cls {
249249
if (!queue.length)
250250
return
251251

252+
// helper class to manage script execution
253+
class Script {
254+
constructor (opts) {
255+
const {
256+
node,
257+
timer,
258+
...runScriptOpts
259+
} = opts
260+
this.node = node
261+
this.timer = timer
262+
this.opts = runScriptOpts
263+
this.pkg = opts.pkg
264+
this.path = opts.path
265+
this.env = opts.env
266+
this.ref = this.node.target.path
267+
268+
if (!Script.runPromises)
269+
Script.runPromises = new Map()
270+
}
271+
272+
// when the script to be run belongs to a link node, this method
273+
// retrieves a list of all other link nodes that are direct dependencies
274+
// of the current node, their scripts also have to be part of the current
275+
// queue, otherwise it just returns an empty list
276+
// this is necessary in order to enable workspaces lifecycle scripts
277+
// that depend on each other
278+
linkLinkedDeps () {
279+
const res = []
280+
281+
if (!this.node.isLink)
282+
return res
283+
284+
for (const edge of this.node.target.edgesOut.values()) {
285+
if (edge.to.isLink &&
286+
queue.some(node => node.isLink && node.target.name === edge.name))
287+
res.push(edge.to.target.path)
288+
}
289+
290+
return res
291+
}
292+
293+
// use the list of depended scripts from linkLinkedDeps()
294+
// and returns a Promise that resolves once all scripts
295+
// this current script depends are resolved
296+
async linkLinkedDepsResolved () {
297+
const resolved = (ref) => {
298+
const p = Script.runPromises.get(ref)
299+
// polling mechanism used in case the depended script is not
300+
// yet set in Script.runPromises, this allow us to keep the
301+
// alphabetical order of execution at first and the dependency
302+
// mechanism for linked deps is only used when necessary
303+
if (!p) {
304+
return new Promise(res =>
305+
setTimeout(() => res(resolved(ref)), 1))
306+
}
307+
return p
308+
}
309+
310+
return await Promise.all(
311+
this.linkLinkedDeps().map(ref => resolved(ref)))
312+
}
313+
314+
async run () {
315+
// in case the current script belongs to a link node that also has
316+
// linked nodes dependencies (e.g: workspaces that depend on each other)
317+
// then we await for that dependency script to be resolved before
318+
// executing the current script
319+
await this.linkLinkedDepsResolved()
320+
321+
// executes the current script using @npmcli/run-script
322+
const p = runScript(this.opts)
323+
324+
// keeps a pool of executing scripts in order to track
325+
// inter-dependencies between these scripts
326+
Script.runPromises.set(this.ref, p)
327+
return p
328+
}
329+
}
330+
252331
process.emit('time', `build:run:${event}`)
253332
const stdio = this.options.foregroundScripts ? 'inherit' : 'pipe'
254333
const limit = this.options.foregroundScripts ? 1 : undefined
255-
await promiseCallLimit(queue.map(node => async () => {
256-
const {
257-
path,
258-
integrity,
259-
resolved,
260-
optional,
261-
peer,
262-
dev,
263-
devOptional,
264-
package: pkg,
265-
location,
266-
} = node.target
267-
268-
// skip any that we know we'll be deleting
269-
if (this[_trashList].has(path))
270-
return
271-
272-
const timer = `build:run:${event}:${location}`
273-
process.emit('time', timer)
274-
this.log.info('run', pkg._id, event, location, pkg.scripts[event])
275-
const env = {
276-
npm_package_resolved: resolved,
277-
npm_package_integrity: integrity,
278-
npm_package_json: resolve(path, 'package.json'),
279-
npm_package_optional: boolEnv(optional),
280-
npm_package_dev: boolEnv(dev),
281-
npm_package_peer: boolEnv(peer),
282-
npm_package_dev_optional:
283-
boolEnv(devOptional && !dev && !optional),
284-
}
285-
const runOpts = {
286-
event,
287-
path,
288-
pkg,
289-
stdioString: true,
290-
stdio,
291-
env,
292-
scriptShell: this[_scriptShell],
293-
}
294-
const p = runScript(runOpts).catch(er => {
295-
const { code, signal } = er
296-
this.log.info('run', pkg._id, event, {code, signal})
297-
throw er
298-
}).then(({args, code, signal, stdout, stderr}) => {
299-
this.scriptsRun.add({
300-
pkg,
334+
await promiseCallLimit(queue
335+
.map(node => {
336+
const {
301337
path,
338+
integrity,
339+
resolved,
340+
optional,
341+
peer,
342+
dev,
343+
devOptional,
344+
package: pkg,
345+
location,
346+
} = node.target
347+
348+
// skip any that we know we'll be deleting
349+
if (this[_trashList].has(path))
350+
return
351+
352+
const timer = `build:run:${event}:${location}`
353+
process.emit('time', timer)
354+
this.log.info('run', pkg._id, event, location, pkg.scripts[event])
355+
const env = {
356+
npm_package_resolved: resolved,
357+
npm_package_integrity: integrity,
358+
npm_package_json: resolve(path, 'package.json'),
359+
npm_package_optional: boolEnv(optional),
360+
npm_package_dev: boolEnv(dev),
361+
npm_package_peer: boolEnv(peer),
362+
npm_package_dev_optional:
363+
boolEnv(devOptional && !dev && !optional),
364+
}
365+
return new Script({
366+
node,
367+
timer,
302368
event,
303-
cmd: args && args[args.length - 1],
369+
path,
370+
pkg,
371+
stdioString: true,
372+
stdio,
304373
env,
305-
code,
306-
signal,
307-
stdout,
308-
stderr,
374+
scriptShell: this[_scriptShell],
309375
})
310-
this.log.info('run', pkg._id, event, {code, signal})
311376
})
377+
.filter(Boolean)
378+
.map(script => async () => {
379+
const p = script.run().catch(er => {
380+
const { code, signal } = er
381+
this.log.info('run', script.pkg._id, event, {code, signal})
382+
throw er
383+
}).then(({args, code, signal, stdout, stderr}) => {
384+
this.scriptsRun.add({
385+
pkg: script.pkg,
386+
path: script.path,
387+
event,
388+
cmd: args && args[args.length - 1],
389+
env: script.env,
390+
code,
391+
signal,
392+
stdout,
393+
stderr,
394+
})
395+
this.log.info('run', script.pkg._id, event, {code, signal})
396+
})
397+
398+
await (this[_doHandleOptionalFailure]
399+
? this[_handleOptionalFailure](script.node, p)
400+
: p)
401+
402+
process.emit('timeEnd', script.timer)
403+
}), limit)
312404

313-
await (this[_doHandleOptionalFailure]
314-
? this[_handleOptionalFailure](node, p)
315-
: p)
405+
// release pool refs to scripts promises
406+
delete Script.runPromises
316407

317-
process.emit('timeEnd', timer)
318-
}), limit)
319408
process.emit('timeEnd', `build:run:${event}`)
320409
}
321410

test/arborist/rebuild.js

+109
Original file line numberDiff line numberDiff line change
@@ -713,3 +713,112 @@ t.test('only rebuild for workspace', async t => {
713713
t.equal(fs.readFileSync(adepTxt, 'utf8'), 'adep', 'adep rebuilt')
714714
t.throws(() => fs.readFileSync(bdepTxt, 'utf8'), { code: 'ENOENT' }, 'bdep not rebuilt')
715715
})
716+
717+
t.test('scripts dependencies between Link nodes', async t => {
718+
// this scenario is laid out as such: the `prepare` script of each linked
719+
// pkg needs the resulting files of its dependency `prepare` script:
720+
//
721+
// prepare: b -> a -> c
722+
// postinstall: e -> a `prepare`
723+
//
724+
// in order to make sure concurrency is handled properly, the `prepare`
725+
// script of "c" takes at least 20ms to complete, while "a" takes at
726+
// least 10ms and the "b" script expects to run synchronously
727+
728+
const path = t.testdir({
729+
'package.json': JSON.stringify({
730+
dependencies: {
731+
a: 'file:./packages/a',
732+
b: 'file:./packages/b',
733+
c: 'file:./packages/c',
734+
d: 'file:./packages/d',
735+
},
736+
}),
737+
node_modules: {
738+
a: t.fixture('symlink', '../packages/a'),
739+
b: t.fixture('symlink', '../packages/b'),
740+
c: t.fixture('symlink', '../packages/c'),
741+
d: t.fixture('symlink', '../packages/d'),
742+
abbrev: {
743+
'package.json': JSON.stringify({
744+
name: 'abbrev',
745+
version: '1.1.1',
746+
}),
747+
},
748+
},
749+
packages: {
750+
a: {
751+
'package.json': JSON.stringify({
752+
name: 'a',
753+
version: '1.0.0',
754+
scripts: {
755+
// on prepare script writes a `index.js` file containing:
756+
// module.exports = require("c")
757+
// this is a slow script though that sleeps for
758+
// at least 10ms prior to writing that file
759+
prepare: "node -e \"setTimeout(() => { require('fs').writeFileSync(require('path').resolve('index.js'), 'module.exports = require(function c(){}.name);') }, 10)\"",
760+
},
761+
dependencies: {
762+
c: '^1.0.0',
763+
},
764+
}),
765+
},
766+
b: {
767+
'package.json': JSON.stringify({
768+
name: 'b',
769+
version: '1.0.0',
770+
scripts: {
771+
// on prepare script requires `./node_modules/a/index.js` which
772+
// is a dependency of this workspace but with the caveat that this
773+
// file is only build during the `prepare` script of "a"
774+
prepare: "node -p \"require('a')\"",
775+
},
776+
dependencies: {
777+
a: '^1.0.0',
778+
abbrev: '^1.0.0',
779+
},
780+
}),
781+
},
782+
c: {
783+
'package.json': JSON.stringify({
784+
name: 'c',
785+
version: '1.0.0',
786+
scripts: {
787+
// on prepare script writes a `index.js` file containing:
788+
// module.exports = "HELLO"
789+
// this is an even slower slower script that sleeps for
790+
// at least 20ms prior to writing that file
791+
prepare: "node -e \"setTimeout(() => { require('fs').writeFileSync(require('path').resolve('index.js'), 'module.exports = function HELLO() {}.name;') }, 20)\"",
792+
},
793+
}),
794+
},
795+
d: {
796+
'package.json': JSON.stringify({
797+
name: 'd',
798+
version: '1.0.0',
799+
}),
800+
},
801+
e: {
802+
'package.json': JSON.stringify({
803+
name: 'd',
804+
version: '1.0.0',
805+
scripts: {
806+
// on postinstall script requires `./node_modules/a/index.js` which
807+
// is a dependency of this workspace but with the caveat that this
808+
// file is only build during the `prepare` script of "a"
809+
// although different lifecycle scripts do not hold refs to
810+
// depending on each other, all `prepare` scripts should already
811+
// have been resolved by the time we run `postinstall` scripts
812+
postinstall: "node -p \"require('a')\"",
813+
},
814+
dependencies: {
815+
a: '^1.0.0',
816+
},
817+
}),
818+
},
819+
},
820+
})
821+
822+
const arb = newArb({ path })
823+
await arb.rebuild()
824+
})

0 commit comments

Comments
 (0)