From e83a78dbf8d46817db86686b10f808c4c468a9cc Mon Sep 17 00:00:00 2001 From: Andrew Dupont Date: Sun, 5 Oct 2025 13:54:51 -0700 Subject: [PATCH 1/4] =?UTF-8?q?Rewrite=20documentation=20on=20services=20t?= =?UTF-8?q?o=20be=20less=20confusing=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit …and more exhaustive. --- ...acting-with-other-packages-via-services.md | 379 ++++++++++++++++-- 1 file changed, 337 insertions(+), 42 deletions(-) diff --git a/docs/infrastructure/interacting-with-other-packages-via-services.md b/docs/infrastructure/interacting-with-other-packages-via-services.md index 5323c42..3dd97b8 100644 --- a/docs/infrastructure/interacting-with-other-packages-via-services.md +++ b/docs/infrastructure/interacting-with-other-packages-via-services.md @@ -14,37 +14,56 @@ Ultimately, packages can see and inspect one another via the {PackageManager "Pa During the package activation phase, Pulsar acts as a matchmaker to providers and consumers of services — “introducing” them to one another whenever two packages match on service name and version. This introduction doesn’t happen until _both_ packages are activated. +## Simple example + +To introduce you to services, we’ll choose a simple example — one where you expect _exactly one_ provider package for a given service. Later we’ll introduce more complex use cases that better match what you might encounter in the real world. + To provide a service, specify a `providedServices` field in your `package.json`. You should include one or more version numbers, each paired with the name of a method on your package's main module: ```json { - "providedServices": { - "my-service": { - "description": "Does a useful thing", - "versions": { - "1.2.3": "provideMyServiceV1", - "2.3.4": "provideMyServiceV2" - } - } - } + "providedServices": { + "my-service": { + "description": "Does a useful thing", + "versions": { + "1.0.0": "provideMyService" + } + } + } } ``` -In your package's main module, implement the methods named above. These methods will be called any time a package is activated that consumes their corresponding service. They should return a value that implements the service's API. +Here we’re creating a new service from scratch, so we’ll use `1.0.0` as the version number. (The `description` field is not consumed by anything, but it’s polite to include it just so other developers understand what the service is designed to do.) + +The code might look something like this: ```js -module.exports = { - activate() { - // ... - }, +// The providing package can define an object like this that describes an API +// contract. +// +// It can be a simple object like this or something like a class instance. +// But remember that `provideMyService` will be called only once for a given +// service/version combination, no matter how many packages end up consuming +// it. +const someServiceObject = { + async getSomeValue() { + return Promise.resolve('something'); + }, + + setSomeValue(value) { + doSomethingWithValue(value); + } +}; - provideMyServiceV1() { - return adaptToLegacyAPI(myService); - }, - provideMyServiceV2() { - return myService; - }, +module.exports = { + activate() { + // ... + }, + + provideMyService() { + return myService; + } }; ``` @@ -52,18 +71,25 @@ Similarly, to consume a service, specify one or more [version _ranges_](https:// ```json { - "consumedServices": { - "another-service": { - "versions": { - "^1.2.3": "consumeAnotherServiceV1", - ">=2.3.4 <2.5": "consumeAnotherServiceV2" - } - } - } + "consumedServices": { + "my-service": { + "versions": { + "^1.0.0": "consumeMyService" + } + } + } } ``` -These methods will be called any time a package is activated that _provides_ their corresponding service. They will receive the service object as an argument. +Here’s how service matching will work in this example: + +1. Pulsar notices that `package1` provides version `1.0.0` of `my-service`. +2. It therefore calls `provideMyService` and files its return value under `my-service@1.0.0`. +3. Later, when activating `package2`, it’ll notices that `package2` says it can consume version `1.0.0` of `my-service`. +4. It’ll look up the value it saved under `my-service@1.0.0`. +5. It’ll then call `consumeMyService` on the main export of `package2`, passing the value from step 4 as the sole argument. + +That’s how our consuming package will receive the service object. How it manages that object will vary based on the service, but for now we’ll just set it as a property so we can use it later on. You will usually need to perform some kind of cleanup in the event that the package providing the service is deactivated. To do this, return a {Disposable} from your service-consuming method: @@ -71,18 +97,287 @@ You will usually need to perform some kind of cleanup in the event that the pack const { Disposable } = require("atom"); module.exports = { - activate() { - // ... - }, - - consumeAnotherServiceV1(service) { - useService(adaptServiceFromLegacyAPI(service)); - return new Disposable(() => stopUsingService(service)); - }, - - consumeAnotherServiceV2(service) { - useService(service); - return new Disposable(() => stopUsingService(service)); - }, + // The `activate` method is guaranteed to run on both the provider and the + // consumer _before_ an introduction is made. + activate() { + this.service = null; + }, + + consumeMyService(service) { + this.useService(service); + return new Disposable(() => this.stopUsingService(service)); + }, + + useService(someService) { + // Typically, all we'd want to do at introduction time is save the service + // so that we can use it later on. + this.service = someService; + }, + + stopUsingService(someService) { + // If this method is called, it means the service will no longer be + // available, so we should null out the reference. This correctly restores + // us to the state we would've been in if the providing package had never + // been activated at all. + if (this.service === someService) { + this.service = null; + } + }, + + async someMethodThatReliesUponTheService() { + if (!this.service) { + // This consumer is not guaranteed to match up with a provider; the user + // might not have any package installed/activated that provides the + // service. So you should anticipate this possibility and try to handle + // it gracefully. + // + // Here we're just silently doing nothing when the service isn't + // available. You may prefer something else, like showing a notification + // to explain to the user why nothing is happening. + return; + } + + let value = await this.service.getSomeValue(); + doSomethingWithValue(value); + } +}; +``` + +If a service consumer matches up with a service provider, the two packages responsible will be introduced to each other _exactly once_ by Pulsar. No such match is guaranteed to take place. Therefore: as described in the code above, your package should be written with the understanding that the service may or may not be available. + +## Service versioning + +You may eventually need to make changes to your service contract. When this happens, you should increment the service’s version number according to [semver](https://semver.org/): + +* If your changes merely add or fix functionality and are backward-compatible, you can increment the minor or patch versions. +* If your changes are _not_ backward-compatible, you should increment the major version number. + +Suppose you change `my-service` in a backwards-incompatible way. You can still provide both versions of `my-service` to a consumer: + +```json +{ + "providedServices": { + "my-service": { + "description": "Does a useful thing", + "versions": { + "1.0.0": "provideMyServiceV1", + "2.0.0": "provideMyServiceV2" + } + } + } +} +``` + + +```js +// What if you figure out how to simplify your service object? +// +// You can keep providing the old service object… +const myServiceV1 = { + async getSomeValue() { + return Promise.resolve('something'); + }, + + setSomeValue(value) { + doSomethingWithValue(value); + } +} + +// …but also provide a new object that offers a different interface to the same +// underlying logic. +const myServiceV2 = { + async doSomeWorkWithValue(value) { + return await doSomeWork(value); + } +}; + + +module.exports = { + activate() { + // ... + }, + + provideMyServiceV1() { + return myServiceV1; + }, + + provideMyServiceV2() { + return myServiceV2; + } +}; +``` + +On the consumer side, you can choose which version of the service you want to consume: + +```json +{ + "consumedServices": { + "my-service": { + "versions": { + "^1.0.0": "consumeMyServiceV1", + "^2.0.0": "consumeMyServiceV2" + } + } + } +} +``` + +```js +const { Disposable } = require("atom"); + +module.exports = { + activate() { + this.service = null; + }, + + useService(someService) { + this.service = someService; + }, + + stopUsingService(someService) { + // If this method is called, it means the service will no longer be + // available, so we should null out the reference. + if (this.service === someService) { + this.service = null; + } + }, + + consumeAnotherServiceV1(service) { + // If you're able to consume two different versions of the same service, + // you'll probably want to wrap the legacy service so that it behaves like + // the new service. This is left as an exercise for the reader. + this.useService(adaptServiceFromLegacyAPI(service)); + return new Disposable(() => this.stopUsingService(service)); + }, + + consumeAnotherServiceV2(service) { + this.useService(service); + return new Disposable(() => this.stopUsingService(service)); + }, + + async someMethodThatReliesUponTheService() { + if (!this.service) { + // This consumer is not guaranteed to match up with a provider; the user + // might not have any package installed/activated that provides the service. + // So you should anticipate this possibility and try to handle it + // gracefully. + // + // Here we're just silently doing nothing when the service isn't + // available. You may prefer something else, like showing a notification + // to explain to the user why nothing is happening. + return; + } + + let value = await this.service.getSomeValue(); + doSomethingWithValue(value); + } +}; +``` + +:::info Version strings + +In the consuming package’s `package.json` we’re specifying versions using strings like `^2.0.0` — meaning “`2.0.0` or anything that starts with `2.`.” These strings can be pretty flexible; [anything understood by NPM](https://semver.npmjs.com/) will also be understood by Pulsar when matching up service providers and consumers. + +So you could instead do something like `>=2.3.4 <2.5` — meaning “`2.3.4`, any `2.3` release _after_ `2.3.4`, or any `2.4` release.” + +::: + +:::tip Redundant introductions + +You might notice that, if both provider and consumer behave as described above, the two packages will technically match _twice_: once for V1 and once for V2. Typically, you’d want to use just one of these, so you could use whatever logic you like for deciding between them. + +You should not assume any particular ordering of the introductions; they’re not guaranteed to happen in the order defined in either package’s `package.json`. + +In this example, you’d probably want to use V2 of the service. So you could write `consumeMyServiceV1` such that it only sets `this.service` if it’s `null`, and `consumeMyServiceV2` such that it sets `this.service` no matter what. + +In more complex scenarios, you’d want to be able to consult more information to help make this decision. But there’s no accompanying metadata when a provider and consumer are introduced. So if you want to be able to consult metadata when making these decisions — version number of the service, the name of the providing package, etc. — then you should make them part of your service object. + +For instance: the `symbol.provider` service requires that a service object have a `packageName` property that specifies the name of the providing package; this allows `symbols-view` to show the user where its symbol data is coming from in case the user wants to privilege one data source over another. But it could also be used in the future to guard against a package trying to provide multiple redundant versions of its service. + +As you can see, this is a burden on the developer. We’d like to make service matchmaking a bit smarter in this scenario and guarantee that, in this situation, we’ll use _only_ the highest possible version number. But this is not yet implemented. + +::: + +## Many-to-many relationships + +Services have another big advantage over hard-coded relationships between two specific packages: a provider can provide a service to any number of consumers, and a consumer can consume a service from any number of providers. + +Suppose `my-service` can be provided by lots of packages, and our consuming package doesn’t want to choose between them. What might that look like? + +```js +const { Disposable } = require("atom"); + +module.exports = { + activate() { + this.services = []; + }, + + useService(someService) { + // Instead of assigning the service to a property, we add it to the + // collection. + if (this.services.includes(someService)) return; + this.services.push(someService); + }, + + stopUsingService(someService) { + let index = this.services.indexOf(someService); + if (index === -1) return; + + // Remove this service object from the collection. + this.services.splice(index, 1); + }, + + consumeMyService(service) { + this.useService(service); + return new Disposable(() => this.stopUsingService(service)); + }, + + // Why would we have multiple consumers? + // + // We could be interested in asking _all_ of them the same question, then + // aggregating the results… + async someMethodThatReliesUponTheServices() { + let promises = this.services.map((serviceObject) => { + return serviceObject.getSomeValue(); + }); + + let values = await Promise.all(promises); + doSomethingWithValues(value); + }, + + // …or we could be interested in picking _one_ provided service among many + // and asking it the question. + async someMethodThatReliesUponASpecificService() { + let winningService = this.services.find((serviceObject) => { + // Use whatever logic you like to decide which service object is the + // right one for this task. + // + // For instance: the `symbol.provider` consumer will call a method on + // each one with some metadata and rely on the service object itself to + // return a score that represents how fit it is for the task. The + // consumer then selects the one with the highest score. + return shouldPickServiceObject(serviceObject); + }); + + if (!winningService) return; + let value = await winningService.getSomeValue(); + doSomethingWithValue(value); + } }; ``` + +This gives us a lot of flexibility. It treats the providers like a room full of smart people. Whenever the consuming package want to know the answer to something, it can stick its head into the room and ask a question; whoever’s in the room _at that moment_ can try to answer it. Sometimes there’ll be a dozen people in the room, and sometimes there might be _zero_ people in the room. + +::: info + +This is how built-in services like `autocomplete.provider` and `symbol.provider` work. If you’re working in a TypeScript project and you start typing something, the `autocomplete-plus` package will ask its providers what could finish it, and will show the results in a menu. We’d expect a package like {pulsar-ide-typescript} to have suggestions to provide. + +But if you were to do the same in a Python project, `autocomplete-plus` would ask a different group of providers. Because it doesn’t activate until you use a JavaScript or TypeScript file, {pulsar-ide-typescript} probably won’t even be active inside of a Python project — and if it is, it certainly won’t speak up if asked to complete some Python code. + +::: + +So it’s easy to illustrate how one consumer can pull from many providers. What about the reverse? + +Suppose you didn’t like `autocomplete-plus` at all and wanted to rewrite it from scratch. Luckily, you wouldn’t have to start _entirely_ from scratch, because you have access to the same providers as `autocomplete-plus`! Simply register your package as a consumer of the `autocomplete.provider` service. + +From the providing package’s perspective, this is normal. In our example, if twenty packages want to consume `my-service`, then `provideMyService` will be called twenty different times. This works just fine by design; nothing special needs to be done to enable it. From 59bb55cc2f29ebb242a44872be6ec31290955078 Mon Sep 17 00:00:00 2001 From: Andrew Dupont Date: Sun, 5 Oct 2025 13:55:12 -0700 Subject: [PATCH 2/4] Remove inaccurate information about publishing to PPM --- docs/developing-for-pulsar/publishing.md | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/docs/developing-for-pulsar/publishing.md b/docs/developing-for-pulsar/publishing.md index 641d7dd..cbbebea 100644 --- a/docs/developing-for-pulsar/publishing.md +++ b/docs/developing-for-pulsar/publishing.md @@ -56,13 +56,7 @@ $ pulsar -p publish minor -If this is the first package you are publishing, the `pulsar -p publish` command -may prompt you for your GitHub username and password. If you have two-factor -authentication enabled, use a [personal access token](https://help.github.com/articles/creating-a-personal-access-token-for-the-command-line/) -in lieu of a password. This is required to publish and you only need to enter -this information the first time you publish. The credentials are stored securely -in your [keychain]() once you -login. +If this is the first package you are publishing, the `pulsar -p publish` command may prompt you to authenticate. Your package is now published and available on Pulsar Package Repository. Head on over to `https://web.pulsar-edit.dev/packages/your-package-name` to see your From e9d0f1b4400a22ab0b3c6bb399f99978aef85d05 Mon Sep 17 00:00:00 2001 From: Andrew Dupont Date: Sat, 15 Nov 2025 11:53:01 -0800 Subject: [PATCH 3/4] =?UTF-8?q?Update=20instructions=20for=20publishing?= =?UTF-8?q?=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit …taking into account much of the changes from #22. --- docs/developing-for-pulsar/publishing.md | 73 +++++++++++++----------- 1 file changed, 40 insertions(+), 33 deletions(-) diff --git a/docs/developing-for-pulsar/publishing.md b/docs/developing-for-pulsar/publishing.md index b4b1a60..fdb6fe7 100644 --- a/docs/developing-for-pulsar/publishing.md +++ b/docs/developing-for-pulsar/publishing.md @@ -3,7 +3,7 @@ title: Publishing layout: doc.ejs --- -Pulsar bundles a command line utility called `ppm` into every installation of Pulsar, to search for and install packages via the command line. This is invoked by using the `pulsar` command with the `-p` or `--package` option. Optionally, you are still able to call PPM directly via `ppm` in the command line. The `pulsar -p` command can also be used to publish Pulsar packages to the public registry and update them. +Pulsar bundles a command line utility called `ppm` into every installation; it lets you search for and install packages via the command line. It’s invoked by using the `pulsar` command with the `-p` or `--package` option. (Usually, though, you are still able to call PPM directly via `ppm` on the command line.) The `pulsar -p` command can also be used to publish Pulsar packages to the public registry and update them. See more in [Using PPM](/contributing-to-pulsar/using-ppm/). @@ -11,61 +11,68 @@ See more in [Using PPM](/contributing-to-pulsar/using-ppm/). There are a few things you should double check before publishing: -- Your `package.json` file has `name`, `description`, and `repository` fields. -- Your `package.json` `name` is URL-safe — meaning it contains no emoji or other special characters that are invalid in a URL. -- Your `package.json` file has a `version` field with a value of `"0.0.0"`. -- Your `package.json` `version` field is [SemVer v2](https://semver.org/spec/v2.0.0.html) compliant. -- Your `package.json` file has an `engines` field that contains an entry for - `atom` such as: `"engines": {"atom": ">=1.0.0 <2.0.0"}`. -- Your package has a `README.md` file at the root. -- Your `repository` URL in the `package.json` file is the same as the URL of - your repository. -- Your package is in a Git repository that has been pushed to - [GitHub](https://github.com). Follow [this guide](https://help.github.com/articles/importing-a-git-repository-using-the-command-line/) if your package isn’t already on GitHub. +- Your package is in a Git repository that has been pushed to [GitHub](https://github.com). Follow [this guide](https://help.github.com/articles/importing-a-git-repository-using-the-command-line/) if your package isn't already on GitHub. +- Your `package.json` file… + - …has a “URL-safe” `name` field — without emoji or special characters. + - …has a `description` field. + - …has a `repository` field containing the URL of your repository. + - …has a `version` field that is [Semver V2](https://semver.org/spec/v2.0.0.html) compliant and has a value of `"0.0.0"` before the first release. + - has an `engine` field that contains an entry for `atom` such as: `"engines": { "atom": ">=1.0.0 <2.0.0" }`. ## Publish your package -Before you publish a package, it’s a good idea to check ahead of time if a package with the same name has already been published to [the Pulsar Package Registry](https://packages.pulsar-edit.dev/packages). You can do that by visiting `https://packages.pulsar-edit.dev/packages/your-package-name` to see if the package already exists. If it does, update your package’s name to something that is available before proceeding. +Before you publish a package, it’s a good idea to check ahead of time if a package with the same name has already been published to [the Pulsar Package Registry](https://packages.pulsar-edit.dev/). You can do that by visiting `https://packages.pulsar-edit.dev/packages/your-package-name` to see if the package already exists. If it does, update your package’s name to something that is available before proceeding. -Now let’s review what the `pulsar -p publish` command does: +If this is your first time publishing, run: -1. Registers the package name on Pulsar Package Registry if it is being published for the first time. -2. Updates the `version` field in the `package.json` file and commits it. -3. Creates a new [Git tag](https://git-scm.com/book/en/Git-Basics-Tagging) for the version being published. -4. Pushes the tag and current branch up to GitHub. -5. Updates Pulsar Package Registry with the new version being published. +```sh +$ pulsar -p login +``` + +This will let you create and set an API token in your [keychain](https://en.wikipedia.org/wiki/Keychain_(software)) to permit interacting with the GitHub API. -Now run the following commands to publish your package: +Now run the following command to publish your package: ```sh -$ cd path-to-your-package $ pulsar -p publish minor ``` - +Here’s what that command does: -If this is the first package you are publishing, the `pulsar -p publish` command may prompt you to authenticate. +1. Registers the package name on Pulsar Package Registry if it is being published for the first time. +2. Updates the `version` field in the `package.json` file — incrementing the minor version, in this example — and commits it. +3. Creates a new [Git tag](https://git-scm.com/book/en/Git-Basics-Tagging) for the version being published. +4. Pushes the tag and current branch up to GitHub. +5. Updates Pulsar Package Registry with the new version being published. Your package is now published and available on Pulsar Package Registry. Head on over to `https://packages.pulsar-edit.dev/packages/your-package-name` to see your package’s page. -With `pulsar -p publish`, you can bump the version and publish by using +## Advanced usage of `publish` + +How else can we use the `publish` command? Let’s ask for its usage information via `pulsar -p help publish`: -```sh -$ pulsar -p publish ``` +Usage: ppm publish [ | major | minor | patch] + ppm publish --tag + ppm publish --rename +``` + +This tells us some useful things. -where `version-type` can be `major`, `minor`, or `patch`: +First: we can specify an exact version number we want to use for the new release… or we can specify `major`, `minor`, or `patch`: -- `major` when you make backwards-incompatible API changes, -- `minor` when you add functionality in a backwards-compatible manner, or -- `patch` when you make backwards-compatible bug fixes. +* `major` when you make backwards-incompatible API changes (`1.0.0` becomes `2.0.0`), +* `minor` when you add functionality in a backwards-compatible manner (`1.0.0` becomes `1.1.0`), or +* `patch` when you make backwards-compatible bug fixes (`1.0.0` becomes `1.0.1`). -For instance, to bump a package from v1.**0**.0 to v1.**1**.0: +For instance, if all you’ve done since the last release is fix small bugs, you’ll probably want to run this command to publish: ```sh -$ pulsar -p publish minor +$ pulsar -p publish patch ``` Check out [semantic versioning](https://semver.org/) to learn more about best practices for versioning your package releases. -You can also run `pulsar -p help publish` to see all the available options and `pulsar -p help` to see all the other available commands. +Second: If we have an existing tag that we want to publish — instead of asking `ppm` to create a new tag for us — we can specify that instead! Keep in mind that it must be of the form `vx.y.z` — for example, `ppm publish --tag v1.12.0` — and the tag must not have been published previously. + +Finally: we can also rename our package at publish time! `ppm` handles the chore of changing the name in `package.json` and tells the Pulsar Package Registry about the new name. The registry takes care of updating the record and ensuring that the old name is never used again (to prevent supply chain attacks). All users that are subscribed to our package under the old name will still be notified about the new version and will have their local copy renamed once they download the update. From ec1fbb1b7ace22247438146f42818dbe60595a84 Mon Sep 17 00:00:00 2001 From: Andrew Dupont Date: Sat, 15 Nov 2025 14:49:47 -0800 Subject: [PATCH 4/4] Forgot an ellipsis --- docs/developing-for-pulsar/publishing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developing-for-pulsar/publishing.md b/docs/developing-for-pulsar/publishing.md index fdb6fe7..d0168f0 100644 --- a/docs/developing-for-pulsar/publishing.md +++ b/docs/developing-for-pulsar/publishing.md @@ -17,7 +17,7 @@ There are a few things you should double check before publishing: - …has a `description` field. - …has a `repository` field containing the URL of your repository. - …has a `version` field that is [Semver V2](https://semver.org/spec/v2.0.0.html) compliant and has a value of `"0.0.0"` before the first release. - - has an `engine` field that contains an entry for `atom` such as: `"engines": { "atom": ">=1.0.0 <2.0.0" }`. + - …has an `engine` field that contains an entry for `atom` such as: `"engines": { "atom": ">=1.0.0 <2.0.0" }`. ## Publish your package