Skip to main content

Publishing

You can publish your extension libraries for either yourself to use in other projects, or for others to take advantage of your hard work.

what you'll learn
  • How to publish extension libraries
  • How to make your extension library properly usable
  • Best practices to make your library discoverable
  • Limitations with existing infrastructure

Overview

So you're ready to publish your extension library.

Before you do-so, there's a few things unique to extension libraries that you need to be aware of.

Peer dependency

When you call extendMethods, you are adding your method to a global record of methods. This record gets used whenever someone calls expect.

The problem, is that npm will create a different instance of @rbxts/expect for the consumer and the user. This is done to avoid dependency conflicts, but it also means you can't share state between your library and the consumer's @rbxts/expect.

To avoid this, your library should not have a direct dependency on @rbxts/expect. Instead, it should have use the devDependency scope, and expose @rbxts/expect under the peerDependencies scope.

// don't do this!
"dependencies": {
"@rbxts/expect": "^1.0.1"
}
"devDependencies": {
"@rbxts/expect": "^1.0.1"
},
"peerDependencies": {
"@rbxts/expect": "^1.0.0"
}

The @rbxts/expect under devDependencies specifies the version that your library actually uses.

The @rbxts/expect under peerDependencies specifies the version that your library expects consumers to have. You generally want this to be the same major version as what you built with, without regard for the minor or patch version; this way, you don't need to worry about updating your library everytime a new minor or patch version of @rbxts/expect comes out.

Flattening exports

Depending on how you intend your library to be consumed, you may need to flatten your exports.

For example, lets say we had the following folder stucture:

src/
index.ts
extensions/
none/
index.ts
some/
index.ts

As it stands, for consumers to properly use our extensions, they would need to have imports like so:

import "@rbxts/expect-myextensions/extensions/none";
import "@rbxts/expect-myextensions/extensions/some";

This is generally undesirable, and considered overtly verbose. To avoid this, we can flatten our exports.

file structure
src/
index.ts
extensions/
index.ts
none/
index.ts
some/
index.ts
src/extensions/index.ts
import "./none";
import "./some";
src/index.ts
import "./extensions";

Then, our consumer can import all our extensions at once.

import "@rbxts/expect-myextensions";

When you shouldn't flatten

If your library uses multiple entry points for collision purposes, flattening your exports may be undesirable.

For example, let's say we had an extension library that published different types for promises.

file structure
src/
index.ts
extensions/
equal/
index.ts
promise-extensions/
equal/
index.ts
src/extensions/equal/index.ts
import { ExpectMessageBuilder, place, CustomMethodImpl, extendMethods } from "@rbxts/expect";

const baseMessage = new ExpectMessageBuilder(`Expected ${place.name} to ${place.not} equal ${place.expected.value}`);

const equal: CustomMethodImpl<defined> = (_, actual, expected: defined) => {
const message = baseMessage.use().expectedValue(expected);

return actual === expected ? message.pass() : message.fail();
};

declare module "@rbxts/expect" {
interface Assertion<T> {
equal<R = T>(expectedValue: R): Assertion<R>;
}
}

extendMethods({
equal: equal,
});
src/promise-extensions/equal/index.ts
import { ExpectMessageBuilder, place, CustomMethodImpl, extendMethods } from "@rbxts/expect";

const baseMessage = new ExpectMessageBuilder(`Expected ${place.name} to ${place.not} equal ${place.expected.value}`);

const equal: CustomMethodImpl<Promise<defined>> = (_, actual, expected: defined) => {
const message = baseMessage.use().expectedValue(expected);

return actual.expect() === expected ? message.pass() : message.fail();
};

declare module "@rbxts/expect" {
interface Assertion<T> {
equal<R = T>(expectedValue: R): Assertion<R>;
}
}

extendMethods({
equal: equal,
});

If we tried flattening these exports, either the bundler would create collision suffixes (eg; equal and equal$1), or we would get an error.

To avoid this, we may want consumers to import these directories directly.

import "@rbxts/expect-myextensions/extensions";

// or for promises
import "@rbxts/expect-myextensions/promise-extensions";

In this case, just flatten your exports up to the directory level, but not at the root index.ts level.

Namespace

If you want to publish your own extension library for others to use, you should do so with the format @rbxts/expect-{EXTENSION_NAME}, so your library can be easily found by searching npm for @rbxts/expect extension libraries.

You don't have to do this, but it helps make your extension library more discoverable.

API extractor

If youre using api-extractor to document your API, then your augmented modules will not be picked up during the rollup phase. This is due to an open bug with api-extractor.

Fixes

There's a couple ways to fix this, but they come with their own issues.

External rollup

You can use tsup for the rollup, and then feed the generated .d.ts file into api-extractor. But this solution comes with its own problems, and you lose source linkage for api warnings.

Exporting functions

You can export your methods alongside the augmented ones- and provide documentation on those.

import { ExpectMessageBuilder, place, CustomMethodImpl, extendMethods } from "@rbxts/expect";

const baseMessage = new ExpectMessageBuilder(`Expected ${place.name} to ${place.not} equal ${place.expected.value}`);

/**
* Asserts that the value is _shallow_ equal to the `expectedValue`.
*/
export const shallowEqual: CustomMethodImpl<defined> = (_, actual, expected: defined) => {
const message = baseMessage.use().expectedValue(expected);

return actual === expected ? message.pass() : message.fail();
};

declare module "@rbxts/expect" {
interface Assertion<T> {
/**
* Asserts that the value is _shallow_ equal to the `expectedValue`.
*/
shallowEqual<R = T>(expectedValue: R): Assertion<R>;
}
}

extendMethods({
shallowEqual: shallowEqual,
});

But this is verbose, and doesn't necessarily come out as you may hope for method types.

Future work

It doesn't seem like the bug with api-extractor will be fixed any-time soon.

Ideally, I'd like to personally contribute a fix for this to the api-extractor repo, but I don't currently have the time to do so.

For now, @rbxts/expect is using a combination of the external rollup and some post processing scripts to circumvent this issue. But long-term, this will need to be fixed at the root.

Example

To see an end-to-end example of what publishing a library looks like, you can checkout the Extension Library Example on GitHub.

Summary

Let's recap what we've learned about publishing:

  • Your library needs to expose a peer dependency on @rbxts/expect
  • Your library should NOT have a direct dependency on @rbxts/expect, but should have a dev dependency instead
  • Your exports make need to be flattened if you want consumers to only have to import a single file
  • To ensure your library is discoverable, it should be published under the namespace @rbxts/expect-{EXTENSION_NAME}