@pnpm/workspace.injected-deps-syncer 1100.0.39 → 1100.0.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # @pnpm/workspace.injected-deps-syncer
2
2
 
3
+ ## 1100.0.40
4
+
5
+ ### Patch Changes
6
+
7
+ - `pnpm install` no longer fails for an injected workspace dependency whose package publishes from a `publishConfig.directory` that its own `prepare` script builds. The injected copy now picks up that directory once `prepare` finishes building it. `pnpm install --frozen-lockfile` no longer reports the dependency as outdated while the directory has not been built yet. [pnpm/pnpm#7811](https://github.com/pnpm/pnpm/issues/7811)
8
+
9
+ - With `sharedWorkspaceLockfile: false`, an injected workspace package that has lifecycle scripts is now hard linked into the projects that depend on it. Before, pnpm left a plain copy, so later edits to the package did not reach those projects [#9828](https://github.com/pnpm/pnpm/issues/9828).
10
+
11
+ - Scripts listed in `syncInjectedDepsAfterScripts` now update injected dependencies while they run. A watcher on the injected package, such as a dev server, sees each change before the script exits [pnpm/pnpm#4410](https://github.com/pnpm/pnpm/issues/4410).
12
+
13
+ - `pnpm install` now works in StackBlitz WebContainers on projects without a lockfile. It used to fail there with `ENOENT ... pnpm-lock.yaml`, because pnpm did not recognize the errors that WebContainers return from asynchronous file system calls [#15649](https://github.com/pnpm/pnpm/issues/15649).
14
+
15
+ - Updated dependencies:
16
+ - @pnpm/bins.linker@1100.0.34
17
+ - @pnpm/bins.remover@1100.0.26
18
+ - @pnpm/error@1100.2.1
19
+ - @pnpm/fetching.directory-fetcher@1100.0.36
20
+ - @pnpm/fs.graceful-fs@1100.2.4
21
+ - @pnpm/installing.modules-yaml@1101.0.5
22
+ - @pnpm/pkg-manifest.reader@1100.0.21
23
+ - @pnpm/workspace.projects-reader@1101.1.1
24
+
3
25
  ## 1100.0.39
4
26
 
5
27
  ### Patch Changes
@@ -62,6 +62,19 @@ export interface ExtendFilesMapOptions {
62
62
  * into an inodes map, which is a map from relative path of every file and directory to their inode type.
63
63
  */
64
64
  export declare function extendFilesMap({ filesMap, filesStats }: ExtendFilesMapOptions): Promise<InodeMap>;
65
+ /** A source directory's files, read once per poll and published to each of its injected copies. */
66
+ export interface PublishSource {
67
+ dir: string;
68
+ map: InodeMap;
69
+ }
70
+ export declare function readPublishSource(dir: string): Promise<PublishSource>;
71
+ /**
72
+ * Copy changed files into `targetDir` as independent files, so a watcher on
73
+ * the injected directory sees the write. A hardlink edited in place is
74
+ * republished when its mtime is at least `editedSinceMs`. A copy whose size
75
+ * and mtime already match the source is left alone.
76
+ */
77
+ export declare function publishEditsForWatchers(source: PublishSource, targetDir: string, editedSinceMs: number): Promise<void>;
65
78
  export declare class DirPatcher {
66
79
  private readonly sourceDir;
67
80
  private readonly targetDir;
package/lib/DirPatcher.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import util from 'node:util';
3
+ import { pipeline } from 'node:stream/promises';
4
+ import { isError } from '@pnpm/error';
4
5
  import { fetchFromDir } from '@pnpm/fetching.directory-fetcher';
5
- import { renameFileWithRetry } from '@pnpm/fs.graceful-fs';
6
+ import { renameFileWithRetry, renameFileWithRetryAsync } from '@pnpm/fs.graceful-fs';
6
7
  import { pathTemp } from 'path-temp';
7
8
  export const DIR = Symbol('Path is a directory');
8
9
  // length comparison should place every directory before the files it contains because
@@ -52,7 +53,7 @@ export async function applyPatch(optimizedDirPatch, sourceDir, targetDir) {
52
53
  await fs.promises.link(sourcePath, targetPath);
53
54
  }
54
55
  catch (error) {
55
- if (util.types.isNativeError(error) && 'code' in error && error.code === 'EXDEV') {
56
+ if (isError(error) && 'code' in error && error.code === 'EXDEV') {
56
57
  await copyIntoPlace(sourcePath, targetPath);
57
58
  return;
58
59
  }
@@ -87,7 +88,7 @@ export async function applyPatch(optimizedDirPatch, sourceDir, targetDir) {
87
88
  await add();
88
89
  }
89
90
  catch (error) {
90
- if (!util.types.isNativeError(error) || !('code' in error) || (error.code !== 'EEXIST')) {
91
+ if (!isError(error) || !('code' in error) || (error.code !== 'EEXIST')) {
91
92
  throw error;
92
93
  }
93
94
  await removeRecursive(targetPath);
@@ -99,7 +100,7 @@ export async function applyPatch(optimizedDirPatch, sourceDir, targetDir) {
99
100
  await fs.promises.rm(targetPath, { recursive: true, force: true });
100
101
  }
101
102
  catch (error) {
102
- if (!util.types.isNativeError(error) || !('code' in error) || (error.code !== 'ENOENT')) {
103
+ if (!isError(error) || !('code' in error) || (error.code !== 'ENOENT')) {
103
104
  throw error;
104
105
  }
105
106
  }
@@ -161,6 +162,105 @@ export async function extendFilesMap({ filesMap, filesStats }) {
161
162
  return result;
162
163
  }
163
164
  const fileId = (stats) => `${stats.dev}:${stats.ino}`;
165
+ const WATCH_MTIME_TOLERANCE_MS = 1;
166
+ const WATCH_FETCH_OPTIONS = { resolveSymlinks: false };
167
+ export async function readPublishSource(dir) {
168
+ return { dir, map: await extendFilesMap(await fetchFromDir(dir, WATCH_FETCH_OPTIONS)) };
169
+ }
170
+ /**
171
+ * Copy changed files into `targetDir` as independent files, so a watcher on
172
+ * the injected directory sees the write. A hardlink edited in place is
173
+ * republished when its mtime is at least `editedSinceMs`. A copy whose size
174
+ * and mtime already match the source is left alone.
175
+ */
176
+ export async function publishEditsForWatchers(source, targetDir, editedSinceMs) {
177
+ const { dir: sourceDir, map: sourceMap } = source;
178
+ const targetMap = await extendFilesMap(await fetchFromDir(targetDir, WATCH_FETCH_OPTIONS));
179
+ const removed = Object.keys(targetMap)
180
+ .filter(relPath => !(relPath in sourceMap) && relPath !== '.')
181
+ .sort(comparePaths)
182
+ .reverse();
183
+ for (const relPath of removed) {
184
+ await removePath(path.join(targetDir, relPath)); // eslint-disable-line no-await-in-loop
185
+ }
186
+ const sourcePaths = Object.keys(sourceMap).sort(comparePaths);
187
+ for (const relPath of sourcePaths) {
188
+ if (sourceMap[relPath] !== DIR || relPath === '.')
189
+ continue;
190
+ const targetPath = path.join(targetDir, relPath);
191
+ if (targetMap[relPath] != null && targetMap[relPath] !== DIR) {
192
+ await removePath(targetPath); // eslint-disable-line no-await-in-loop
193
+ }
194
+ await fs.promises.mkdir(targetPath, { recursive: true }); // eslint-disable-line no-await-in-loop
195
+ }
196
+ for (const relPath of sourcePaths) {
197
+ const sourceValue = sourceMap[relPath];
198
+ if (typeof sourceValue !== 'string')
199
+ continue;
200
+ const sourcePath = path.join(sourceDir, relPath);
201
+ const targetPath = path.join(targetDir, relPath);
202
+ const sourceStat = await fs.promises.stat(sourcePath); // eslint-disable-line no-await-in-loop
203
+ const targetValue = targetMap[relPath];
204
+ const targetStat = typeof targetValue === 'string'
205
+ ? await statFile(targetPath) // eslint-disable-line no-await-in-loop
206
+ : null;
207
+ if (!shouldPublish({ sourceStat, targetStat, sourceId: sourceValue, targetValue, editedSinceMs }))
208
+ continue;
209
+ await copyForWatchers(sourcePath, targetPath, sourceStat); // eslint-disable-line no-await-in-loop
210
+ }
211
+ }
212
+ function shouldPublish({ sourceStat, targetStat, sourceId, targetValue, editedSinceMs }) {
213
+ if (targetStat == null || typeof targetValue !== 'string')
214
+ return true;
215
+ if (targetValue === sourceId)
216
+ return sourceStat.mtimeMs >= editedSinceMs;
217
+ return sourceStat.size !== targetStat.size ||
218
+ Math.abs(sourceStat.mtimeMs - targetStat.mtimeMs) > WATCH_MTIME_TOLERANCE_MS;
219
+ }
220
+ async function statFile(filePath) {
221
+ try {
222
+ return await fs.promises.stat(filePath);
223
+ }
224
+ catch (error) {
225
+ if (isEnoent(error))
226
+ return null;
227
+ throw error;
228
+ }
229
+ }
230
+ function isEnoent(error) {
231
+ return isError(error) && 'code' in error && error.code === 'ENOENT';
232
+ }
233
+ async function removePath(targetPath) {
234
+ await fs.promises.rm(targetPath, { recursive: true, force: true });
235
+ }
236
+ /**
237
+ * Read and write the bytes. `fs.copyFile` may reflink on macOS, and a
238
+ * reflink does not notify watchers the way a new file in the injected
239
+ * directory does.
240
+ */
241
+ async function copyForWatchers(sourcePath, targetPath, sourceStat) {
242
+ const existing = await fs.promises.lstat(targetPath).catch((error) => {
243
+ if (isEnoent(error))
244
+ return null;
245
+ throw error;
246
+ });
247
+ if (existing?.isDirectory() === true) {
248
+ await removePath(targetPath);
249
+ }
250
+ await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
251
+ const tempPath = pathTemp(path.dirname(targetPath));
252
+ try {
253
+ await pipeline(fs.createReadStream(sourcePath), fs.createWriteStream(tempPath, { flags: 'wx' }));
254
+ await fs.promises.chmod(tempPath, sourceStat.mode & 0o7777);
255
+ await renameFileWithRetryAsync(tempPath, targetPath);
256
+ }
257
+ catch (error) {
258
+ await fs.promises.rm(tempPath, { force: true });
259
+ throw error;
260
+ }
261
+ // Best effort: a target left with a different mtime is only published again on the next poll.
262
+ await fs.promises.utimes(targetPath, new Date(), sourceStat.mtime).catch(() => { });
263
+ }
164
264
  export class DirPatcher {
165
265
  sourceDir;
166
266
  targetDir;
package/lib/index.d.ts CHANGED
@@ -11,4 +11,39 @@ export interface SyncInjectedDepsOptions {
11
11
  */
12
12
  manifestBeforeScripts?: DependencyManifest;
13
13
  }
14
+ export interface InjectedEditWatch {
15
+ stop: () => Promise<void>;
16
+ }
17
+ /**
18
+ * The source directory and injected copies a running script should publish.
19
+ * `undefined` when the package has no name, no workspace, or no injected copies,
20
+ * or when the modules manifest cannot be read. The sync after the script
21
+ * reports that error, so it does not stop the script from starting.
22
+ */
23
+ export declare function injectedEditDirs(opts: SyncInjectedDepsOptions): Promise<{
24
+ sourceDir: string;
25
+ targetDirs: string[];
26
+ } | undefined>;
27
+ /**
28
+ * Publish injected copies about every 200ms until `stop`. `editedSinceMs` stays
29
+ * two seconds behind the start of the watch, which covers one-second mtime
30
+ * resolution. Stop waits for an in-flight publish so the end-of-script hardlink
31
+ * sync does not run beside it.
32
+ */
33
+ export declare function watchInjectedEdits(sourceDir: string, targetDirs: string[]): InjectedEditWatch;
14
34
  export declare function syncInjectedDeps(opts: SyncInjectedDepsOptions): Promise<void>;
35
+ export interface SyncInjectedDepsOfModulesDirOptions {
36
+ /** The directory whose lockfile lists the injected dependencies. */
37
+ lockfileDir: string;
38
+ /** The modules directory that holds the `.modules.yaml` of `lockfileDir`. */
39
+ modulesDir: string;
40
+ /** The sources whose copies are synced. Copies of other sources keep their contents. */
41
+ sourceDirs: ReadonlySet<string>;
42
+ }
43
+ /**
44
+ * Brings the injected copies listed in one modules directory back in step
45
+ * with their sources. A project with its own lockfile injects copies of
46
+ * workspace projects whose lifecycle scripts run in their own install, so
47
+ * nothing else syncs those copies after the scripts run.
48
+ */
49
+ export declare function syncInjectedDepsOfModulesDir(opts: SyncInjectedDepsOfModulesDirOptions): Promise<void>;
package/lib/index.js CHANGED
@@ -1,15 +1,94 @@
1
+ import fs from 'node:fs';
1
2
  import path from 'node:path';
2
3
  import { linkBins, linkBinsOfPackages } from '@pnpm/bins.linker';
3
4
  import { removeBin } from '@pnpm/bins.remover';
4
5
  import { getBinsFromPackageManifest } from '@pnpm/bins.resolver';
5
- import { PnpmError } from '@pnpm/error';
6
+ import { isError, PnpmError } from '@pnpm/error';
6
7
  import { readModulesManifest } from '@pnpm/installing.modules-yaml';
7
8
  import { logger as createLogger } from '@pnpm/logger';
8
9
  import { safeReadPackageJsonFromDir } from '@pnpm/pkg-manifest.reader';
9
10
  import { findWorkspaceProjectsNoCheck } from '@pnpm/workspace.projects-reader';
10
11
  import normalizePath from 'normalize-path';
11
- import { DirPatcher } from './DirPatcher.js';
12
+ import { DirPatcher, publishEditsForWatchers, readPublishSource } from './DirPatcher.js';
12
13
  const logger = createLogger('skip-sync-injected-deps');
14
+ const watchLogger = createLogger('sync-injected-deps-watch');
15
+ /**
16
+ * The source directory and injected copies a running script should publish.
17
+ * `undefined` when the package has no name, no workspace, or no injected copies,
18
+ * or when the modules manifest cannot be read. The sync after the script
19
+ * reports that error, so it does not stop the script from starting.
20
+ */
21
+ export async function injectedEditDirs(opts) {
22
+ if (!opts.pkgName || opts.workspaceDir == null)
23
+ return undefined;
24
+ let located;
25
+ try {
26
+ located = await readInjectedTargets(opts.workspaceDir, opts.pkgRootDir);
27
+ }
28
+ catch (err) {
29
+ watchLogger.debug({
30
+ err,
31
+ message: `Not publishing injected dependencies of ${opts.pkgRootDir} while its script is running`,
32
+ });
33
+ return undefined;
34
+ }
35
+ if (located?.resolvedTargetDirs == null)
36
+ return undefined;
37
+ return { sourceDir: located.pkgRootDir, targetDirs: located.resolvedTargetDirs };
38
+ }
39
+ /**
40
+ * Publish injected copies about every 200ms until `stop`. `editedSinceMs` stays
41
+ * two seconds behind the start of the watch, which covers one-second mtime
42
+ * resolution. Stop waits for an in-flight publish so the end-of-script hardlink
43
+ * sync does not run beside it.
44
+ */
45
+ export function watchInjectedEdits(sourceDir, targetDirs) {
46
+ const editedSinceMs = Date.now() - 2000;
47
+ let stopped = false;
48
+ let timer;
49
+ let inFlight;
50
+ const publish = () => {
51
+ if (stopped || inFlight != null)
52
+ return;
53
+ inFlight = publishToTargets(sourceDir, targetDirs, editedSinceMs).then(() => {
54
+ inFlight = undefined;
55
+ });
56
+ };
57
+ publish();
58
+ timer = setInterval(publish, 200);
59
+ return {
60
+ stop: async () => {
61
+ stopped = true;
62
+ if (timer != null)
63
+ clearInterval(timer);
64
+ await inFlight;
65
+ },
66
+ };
67
+ }
68
+ async function publishToTargets(sourceDir, targetDirs, editedSinceMs) {
69
+ let source;
70
+ try {
71
+ source = await readPublishSource(sourceDir);
72
+ }
73
+ catch (err) {
74
+ watchLogger.debug({
75
+ err,
76
+ message: `Failed to read injected dependency ${sourceDir} while its script is running`,
77
+ });
78
+ return;
79
+ }
80
+ await Promise.all(targetDirs.map(async (targetDir) => {
81
+ try {
82
+ await publishEditsForWatchers(source, targetDir, editedSinceMs);
83
+ }
84
+ catch (err) {
85
+ watchLogger.debug({
86
+ err,
87
+ message: `Failed to publish injected dependency ${targetDir} while its script is running`,
88
+ });
89
+ }
90
+ }));
91
+ }
13
92
  export async function syncInjectedDeps(opts) {
14
93
  if (!opts.pkgName) {
15
94
  logger.debug({
@@ -22,10 +101,8 @@ export async function syncInjectedDeps(opts) {
22
101
  if (!opts.workspaceDir) {
23
102
  throw new PnpmError('NO_WORKSPACE_DIR', 'Cannot update injected dependencies without workspace dir');
24
103
  }
25
- const pkgRootDir = path.resolve(opts.workspaceDir, opts.pkgRootDir);
26
- const modulesDir = path.resolve(opts.workspaceDir, 'node_modules');
27
- const modules = await readModulesManifest(modulesDir);
28
- if (!modules?.injectedDeps) {
104
+ const located = await readInjectedTargets(opts.workspaceDir, opts.pkgRootDir);
105
+ if (located == null) {
29
106
  logger.debug({
30
107
  reason: 'no-injected-deps',
31
108
  message: 'Skipping sync of injected dependencies because none were detected',
@@ -33,9 +110,8 @@ export async function syncInjectedDeps(opts) {
33
110
  });
34
111
  return;
35
112
  }
36
- const injectedDepKey = normalizePath(path.relative(opts.workspaceDir, pkgRootDir), true);
37
- const targetDirs = modules.injectedDeps[injectedDepKey];
38
- if (!targetDirs || targetDirs.length === 0) {
113
+ const { modules, pkgRootDir, resolvedTargetDirs } = located;
114
+ if (resolvedTargetDirs == null) {
39
115
  logger.debug({
40
116
  reason: 'no-injected-deps',
41
117
  message: `There are no injected dependencies from ${opts.pkgRootDir}`,
@@ -43,7 +119,6 @@ export async function syncInjectedDeps(opts) {
43
119
  });
44
120
  return;
45
121
  }
46
- const resolvedTargetDirs = targetDirs.map(targetDir => path.resolve(opts.workspaceDir, targetDir));
47
122
  const patchers = await DirPatcher.fromMultipleTargets(pkgRootDir, resolvedTargetDirs);
48
123
  await Promise.all(patchers.map(patcher => patcher.apply()));
49
124
  await syncBinLinks({
@@ -59,6 +134,55 @@ export async function syncInjectedDeps(opts) {
59
134
  workspaceDir: opts.workspaceDir,
60
135
  });
61
136
  }
137
+ /**
138
+ * Brings the injected copies listed in one modules directory back in step
139
+ * with their sources. A project with its own lockfile injects copies of
140
+ * workspace projects whose lifecycle scripts run in their own install, so
141
+ * nothing else syncs those copies after the scripts run.
142
+ */
143
+ export async function syncInjectedDepsOfModulesDir(opts) {
144
+ const modules = await readModulesManifest(opts.modulesDir);
145
+ if (!modules?.injectedDeps)
146
+ return;
147
+ await Promise.all(Object.entries(modules.injectedDeps).map(async ([sourceId, targetDirs]) => {
148
+ const sourceDir = path.resolve(opts.lockfileDir, sourceId);
149
+ if (!opts.sourceDirs.has(sourceDir) || targetDirs.length === 0)
150
+ return;
151
+ // A publish directory the build did not produce leaves the copies alone
152
+ // rather than emptying them.
153
+ if (!await dirExists(sourceDir))
154
+ return;
155
+ const resolvedTargetDirs = targetDirs.map((targetDir) => path.resolve(opts.lockfileDir, targetDir));
156
+ const patchers = await DirPatcher.fromMultipleTargets(sourceDir, resolvedTargetDirs);
157
+ await Promise.all(patchers.map(patcher => patcher.apply()));
158
+ }));
159
+ }
160
+ async function dirExists(dir) {
161
+ try {
162
+ await fs.promises.stat(dir);
163
+ return true;
164
+ }
165
+ catch (err) {
166
+ if (isError(err) && 'code' in err && err.code === 'ENOENT')
167
+ return false;
168
+ throw err;
169
+ }
170
+ }
171
+ async function readInjectedTargets(workspaceDir, pkgRootDirInput) {
172
+ const pkgRootDir = path.resolve(workspaceDir, pkgRootDirInput);
173
+ const modules = await readModulesManifest(path.resolve(workspaceDir, 'node_modules'));
174
+ if (modules?.injectedDeps == null)
175
+ return undefined;
176
+ const injectedDepKey = normalizePath(path.relative(workspaceDir, pkgRootDir), true);
177
+ const targetDirs = modules.injectedDeps[injectedDepKey];
178
+ return {
179
+ modules,
180
+ pkgRootDir,
181
+ resolvedTargetDirs: targetDirs == null || targetDirs.length === 0
182
+ ? undefined
183
+ : targetDirs.map(targetDir => path.resolve(workspaceDir, targetDir)),
184
+ };
185
+ }
62
186
  /** The commands a package declares, or none when it declares no bins. */
63
187
  async function readBinNames(pkgDir) {
64
188
  const manifest = await safeReadPackageJsonFromDir(pkgDir);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pnpm/workspace.injected-deps-syncer",
3
- "version": "1100.0.39",
3
+ "version": "1100.0.40",
4
4
  "description": "Update all injected replica of a workspace package",
5
5
  "keywords": [
6
6
  "pnpm",
@@ -27,17 +27,17 @@
27
27
  "!*.map"
28
28
  ],
29
29
  "dependencies": {
30
- "@pnpm/bins.linker": "1100.0.33",
31
- "@pnpm/bins.remover": "1100.0.25",
30
+ "@pnpm/bins.linker": "1100.0.34",
31
+ "@pnpm/bins.remover": "1100.0.26",
32
32
  "@pnpm/bins.resolver": "1100.0.17",
33
- "@pnpm/error": "1100.2.0",
34
- "@pnpm/fetching.directory-fetcher": "1100.0.35",
35
- "@pnpm/fs.graceful-fs": "1100.2.3",
36
- "@pnpm/installing.modules-yaml": "1101.0.4",
37
- "@pnpm/pkg-manifest.reader": "1100.0.20",
33
+ "@pnpm/error": "1100.2.1",
34
+ "@pnpm/fetching.directory-fetcher": "1100.0.36",
35
+ "@pnpm/fs.graceful-fs": "1100.2.4",
36
+ "@pnpm/installing.modules-yaml": "1101.0.5",
37
+ "@pnpm/pkg-manifest.reader": "1100.0.21",
38
38
  "@pnpm/text.ordinal-comparator": "1100.0.0",
39
39
  "@pnpm/types": "1102.1.1",
40
- "@pnpm/workspace.projects-reader": "1101.1.0",
40
+ "@pnpm/workspace.projects-reader": "1101.1.1",
41
41
  "@types/normalize-path": "^3.0.2",
42
42
  "normalize-path": "^3.0.0",
43
43
  "path-temp": "^3.0.0"
@@ -48,8 +48,8 @@
48
48
  "devDependencies": {
49
49
  "@jest/globals": "30.4.1",
50
50
  "@pnpm/logger": "1100.0.0",
51
- "@pnpm/prepare": "1100.0.32",
52
- "@pnpm/workspace.injected-deps-syncer": "1100.0.39",
51
+ "@pnpm/prepare": "1100.0.33",
52
+ "@pnpm/workspace.injected-deps-syncer": "1100.0.40",
53
53
  "@types/is-windows": "^1.0.2",
54
54
  "is-windows": "^1.0.2"
55
55
  },