Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(publish): watch mode #24

Open
wants to merge 24 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,38 @@ _Publish mode_ helps replicate the production environment in your development se
Any changes you make to the _Dependency package_ will be reflected in the `node_modules` directory of the _Consuming package_.

> **Note:** If the _Dependency package_ emits new files, you'll need to re-run `npx link publish <dependency-package-path>` to create new hard links.


#### Watch mode

To automatically re-link a package in publish mode whenever a change is made, use the `--watch` flag:

```sh
npx link publish --watch <dependency-package-path>
```

Hard links do not support directories, so files must be individually linked. _Watch mode_ addresses this limitation by automatically linking new files added to the _Dependency package_ so they appear in the _Consuming package_.

##### Watch mode caveats

A fundamental limitation of _watch mode_ is that it doesn't have a reliable way to know when the rebuild of your local _Dependency package_ is complete.

The **recommended** way to address this limitation is using the `--litmus` flag. This flag specifies a filepath relative to the _Dependency package_ that `link` can check to see if a build is completed.
For example, you might create an empty file in your local dependency called `.build_complete`, and augment it's build system to delete that file at the start of builds, and re-create it at the end of builds. Then pass `npx link --watch --litmus '.build_complete'`

```
"build:watch": "nodemon --watch src --ext .ts,.tsx,.css --exec \"rm -f .build_complete && yarn prepare && yarn pack && touch .build_complete\"",
```

If for some reason you can't do that, you'll have to fall back on some heuristics:

| Flag | Description | Default |
|-------------------------|------------------------------------------------------------------------------------------------------------------------------|---------|
| `--delay` or `-d` | The amount of time (in ms) after a change is observed to refresh the packlist for your _Dependency package_ | 2000 |
| `--interval` or `-i` | The amount of time (in ms) to poll the _Dependency package_ to see if all expected files are present (i.e: build is complete) | 500 |
| `--maxBuildTime` or `-m` | The maximum amount of time (in ms) to poll the _Dependency package_ to see if all expected files are present (i.e: build is complete) | 30000 |

Note that there is an edge case with _watch mode_ that may not work as expected if you don't use the `--litmus` flag; if you intentionally delete a file in the _Dependency package_, you may have to wait `--maxBuildTime` before the links are refreshed.

### Configuration file

Create a `link.config.json` (or `link.config.js`) configuration file at the root of the _Consuming package_ to automatically setup links to multiple _Dependency packages_.
Expand Down
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"bin": "dist/cli.js",
"packageManager": "pnpm@9.4.0",
"scripts": {
"build": "pkgroll --minify",
"build": "pkgroll",
"test": "tsx tests/index.ts",
"dev": "tsx watch tests/index.ts",
"lint": "lintroll --cache --ignore-pattern=tests/fixtures .",
Expand All @@ -29,20 +29,24 @@
},
"devDependencies": {
"@types/cmd-shim": "^5.0.2",
"@types/glob-to-regexp": "^0.4.4",
"@types/node": "^20.14.14",
"@types/npm-packlist": "^7.0.3",
"@types/npmcli__package-json": "^4.0.4",
"clean-pkg-json": "^1.2.0",
"cleye": "^1.3.2",
"cmd-shim": "^6.0.3",
"debounce": "^2.1.0",
"execa": "^8.0.1",
"fs-fixture": "^2.4.0",
"get-node": "^15.0.1",
"glob-to-regexp": "^0.4.1",
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Noticed that this dependency is marked as archived/read-only, while a more popular alternative https://github.com/isaacs/minimatch is still actively maintained (and apparently used by npm internals).

Was there some reason you chose glob-to-regex instead?

"kolorist": "^1.8.0",
"lintroll": "^1.7.1",
"manten": "^1.3.0",
"npm-packlist": "^8.0.2",
"outdent": "^0.8.0",
"p-debounce": "^4.0.0",
"pkgroll": "^2.4.2",
"tsx": "^4.16.5",
"typescript": "^5.5.4"
Expand Down
34 changes: 34 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 6 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { cli } from 'cleye';
import outdent from 'outdent';
import { linkPackage, linkFromConfig } from './link-package';
import { loadConfig } from './utils/load-config';
import { publishCommand, publishHandler } from './commands/publish/index.js';
import { publishCommand, publishHandler } from './commands/publish';

(async () => {
const argv = cli({
Expand Down Expand Up @@ -77,7 +77,11 @@ import { publishCommand, publishHandler } from './commands/publish/index.js';
},
);
} else if (argv.command === 'publish') {
await publishHandler(cwdProjectPath, argv._);
await publishHandler(
cwdProjectPath,
argv._,
argv.flags,
);
}
})().catch((error) => {
console.error('Error:', error.message);
Expand Down
49 changes: 41 additions & 8 deletions src/commands/publish/hardlink-package.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,25 @@
import path from 'node:path';
import fs from 'node:fs/promises';
import { green, magenta, cyan } from 'kolorist';
import {
green, red, magenta, cyan,
} from 'kolorist';
import type { PackageJsonWithName } from '../../utils/read-package-json';
import { hardlink } from '../../utils/symlink';
import { getNpmPacklist } from '../../utils/get-npm-packlist';
import { cwdPath } from '../../utils/cwd-path.js';
import { fsExists } from '../../utils/fs-exists';
import { waitFor } from '../../utils/wait-for';

export const hardlinkPackage = async (
linkPath: string,
absoluteLinkPackagePath: string,
packageJson: PackageJsonWithName,
publishFilesPromise: string[] | Promise<string[]> = getNpmPacklist(
absoluteLinkPackagePath,
packageJson,
),
interval: number = 500,
maxBuildTime: number = 30000,
) => {
const [oldPublishFiles, publishFiles] = await Promise.all([
getNpmPacklist(
Expand All @@ -21,13 +31,21 @@ export const hardlinkPackage = async (
*/
packageJson,
),
getNpmPacklist(
absoluteLinkPackagePath,
packageJson,
),
publishFilesPromise,
]);

console.log(`Linking ${magenta(packageJson.name)} in publish mode:`);

await Promise.all(publishFiles.map(async (file) => {
const sourcePath = path.join(absoluteLinkPackagePath, file);
await waitFor(
async () => await fsExists(sourcePath),
interval,
maxBuildTime,
'',
);
}));

await Promise.all(
publishFiles.map(async (file) => {
const sourcePath = path.join(absoluteLinkPackagePath, file);
Expand All @@ -38,8 +56,20 @@ export const hardlinkPackage = async (
{ recursive: true },
);

await hardlink(sourcePath, targetPath);
try {
await hardlink(sourcePath, targetPath);
} catch (error) {
console.warn(
` ${red('✖ Failed to link')}`,
cyan(cwdPath(targetPath)),
'→',
cyan(cwdPath(sourcePath)),
(error as Error).message ?? error,
);
return;
}

// Don't delete files that are still in the new publish list
const fileIndex = oldPublishFiles.indexOf(file);
if (fileIndex > -1) {
oldPublishFiles.splice(fileIndex, 1);
Expand All @@ -54,10 +84,13 @@ export const hardlinkPackage = async (
}),
);

// Delete files that are no longer in the new publish list
await Promise.all(
oldPublishFiles.map(async (file) => {
const cleanPath = path.join(linkPath, file);
await fs.rm(cleanPath);
console.log(cyan(` 🚮 ${file} no longer in publish list, deleting it. If you did not intend to do this, something probably went wrong. See https://github.com/privatenumber/link?tab=readme-ov-file#publish-mode`));
await fs.rm(path.join(linkPath, file), {
force: true,
});
}),
);
};
45 changes: 40 additions & 5 deletions src/commands/publish/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,34 @@ export const publishCommand = command({
name: 'publish',
parameters: ['<package paths...>'],
flags: {
// watch: {
// type: Boolean,
// alias: 'w',
// description: 'Watch for changes in the package and automatically relink',
// },
watch: {
type: Boolean,
alias: 'w',
description: 'Watch for changes in the package and automatically relink',
},
litmus: {
type: String,
alias: 'l',
description: "If using the --watch flag, look for this file in the linked package to see if it's ready to re-link",
},
delay: {
type: Number,
alias: 'd',
description: 'If using the --watch flag without the litmus flag, wait this amount of time (in ms) after detecting changes before refreshing the packlist and re-linking',
default: 2000,
},
interval: {
type: Number,
alias: 'i',
description: 'If using the --watch flag, poll for completed builds at this frequency (in ms)',
default: 500,
},
maxBuildTime: {
type: Number,
alias: 'm',
description: 'If using the --watch flag, the maximum amount of time to wait for all expected files to appear before re-linking',
default: 30000,
},
},
help: {
description: 'Link a package to simulate an environment similar to `npm install`',
Expand All @@ -19,13 +42,25 @@ export const publishCommand = command({
export const publishHandler = async (
cwdProjectPath: string,
packagePaths: string[],
flags: {
watch?: boolean,
litmus?: string,
delay: number,
interval: number,
maxBuildTime: number,
},
) => {
if (packagePaths.length > 0) {
await Promise.all(
packagePaths.map(
linkPackagePath => linkPublishMode(
cwdProjectPath,
linkPackagePath,
flags.watch,
flags.litmus,
flags.delay,
flags.interval,
flags.maxBuildTime,
),
),
);
Expand Down
Loading