Skip to content

Commit 2ce5d27

Browse files
authored
Add support for yarn add jsr:@foo/bar (#6757)
## What's the problem this PR addresses? My PR at #6752 added support for the `jsr:` protocol, but in a way that didn't allow for running `yarn add jsr:@foo/bar` (or even `yarn add @foo/bar@jsr:1.0.0`), because it was setup as a conversion pass in dependency maps - ie it didn't integrate well with `suggestUtils`. ## How did you fix it? I refactored the code to now use a proper resolver & fetcher. A hack remains (see description of `getResolutionDependencies`); it's a medium-term solution. I have a better fix in mind, but that will require some refactoring of our internals so it'll be deferred until the next major. ## Checklist <!--- Don't worry if you miss something, chores are automatically tested. --> <!--- This checklist exists to help you remember doing the chores when you submit a PR. --> <!--- Put an `x` in all the boxes that apply. --> - [x] I have read the [Contributing Guide](https://yarnpkg.com/advanced/contributing). <!-- See https://yarnpkg.com/advanced/contributing#preparing-your-pr-to-be-released for more details. --> <!-- Check with `yarn version check` and fix with `yarn version check -i` --> - [x] I have set the packages that need to be released for my changes to be effective. <!-- The "Testing chores" workflow validates that your PR follows our guidelines. --> <!-- If it doesn't pass, click on it to see details as to what your PR might be missing. --> - [x] I will check that all automated PR checks pass before the PR gets reviewed.
1 parent 21a6c9e commit 2ce5d27

File tree

10 files changed

+281
-35
lines changed

10 files changed

+281
-35
lines changed

.yarn/versions/3749f790.yml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
releases:
2+
"@yarnpkg/cli": minor
3+
"@yarnpkg/core": minor
4+
"@yarnpkg/plugin-essentials": minor
5+
6+
declined:
7+
- "@yarnpkg/plugin-compat"
8+
- "@yarnpkg/plugin-constraints"
9+
- "@yarnpkg/plugin-dlx"
10+
- "@yarnpkg/plugin-exec"
11+
- "@yarnpkg/plugin-file"
12+
- "@yarnpkg/plugin-git"
13+
- "@yarnpkg/plugin-github"
14+
- "@yarnpkg/plugin-http"
15+
- "@yarnpkg/plugin-init"
16+
- "@yarnpkg/plugin-interactive-tools"
17+
- "@yarnpkg/plugin-jsr"
18+
- "@yarnpkg/plugin-link"
19+
- "@yarnpkg/plugin-nm"
20+
- "@yarnpkg/plugin-npm"
21+
- "@yarnpkg/plugin-npm-cli"
22+
- "@yarnpkg/plugin-pack"
23+
- "@yarnpkg/plugin-patch"
24+
- "@yarnpkg/plugin-pnp"
25+
- "@yarnpkg/plugin-pnpm"
26+
- "@yarnpkg/plugin-stage"
27+
- "@yarnpkg/plugin-typescript"
28+
- "@yarnpkg/plugin-version"
29+
- "@yarnpkg/plugin-workspace-tools"
30+
- "@yarnpkg/builder"
31+
- "@yarnpkg/doctor"
32+
- "@yarnpkg/extensions"
33+
- "@yarnpkg/nm"
34+
- "@yarnpkg/pnpify"
35+
- "@yarnpkg/sdks"

packages/acceptance-tests/pkg-tests-specs/sources/protocols/jsr.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,34 @@ import {tests} from 'pkg-tests-core';
44

55
describe(`Protocols`, () => {
66
describe(`jsr:`, () => {
7+
test(
8+
`is should allow adding a package with "yarn add"`,
9+
makeTemporaryEnv(
10+
{},
11+
async ({path, run, source}) => {
12+
await xfs.writeFilePromise(ppath.join(path, `.yarnrc.yml`), JSON.stringify({
13+
[`npmScopes`]: {
14+
[`jsr`]: {
15+
[`npmRegistryServer`]: `${await tests.startPackageServer()}/registry/jsr`,
16+
},
17+
},
18+
}));
19+
20+
await run(`add`, `jsr:no-deps-jsr`);
21+
22+
await expect(source(`require('no-deps-jsr')`)).resolves.toMatchObject({
23+
name: `@jsr/no-deps-jsr`,
24+
});
25+
26+
await expect(xfs.readJsonPromise(ppath.join(path, `package.json`))).resolves.toMatchObject({
27+
dependencies: {
28+
[`no-deps-jsr`]: `jsr:^1.0.0`,
29+
},
30+
});
31+
},
32+
),
33+
);
34+
735
test(
836
`it should allow installing a package from a jsr registry`,
937
makeTemporaryEnv(

packages/plugin-essentials/sources/commands/add.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,10 +161,23 @@ export default class AddCommand extends BaseCommand {
161161
? Infinity
162162
: 1;
163163

164+
const jsrToDescriptor = (pseudoDescriptor: string) => {
165+
const descriptor = structUtils.tryParseDescriptor(pseudoDescriptor.slice(4));
166+
if (!descriptor)
167+
return null;
168+
169+
if (descriptor.range === `unknown`)
170+
return structUtils.makeDescriptor(descriptor, `jsr:${structUtils.stringifyIdent(descriptor)}@latest`);
171+
172+
return structUtils.makeDescriptor(descriptor, `jsr:${descriptor.range}`);
173+
};
174+
164175
const allSuggestions = await Promise.all(this.packages.map(async pseudoDescriptor => {
165176
const request = pseudoDescriptor.match(/^\.{0,2}\//)
166177
? await suggestUtils.extractDescriptorFromPath(pseudoDescriptor as PortablePath, {cwd: this.context.cwd, workspace})
167-
: structUtils.tryParseDescriptor(pseudoDescriptor);
178+
: pseudoDescriptor.startsWith(`jsr:`)
179+
? jsrToDescriptor(pseudoDescriptor)
180+
: structUtils.tryParseDescriptor(pseudoDescriptor);
168181

169182
const unsupportedPrefix = pseudoDescriptor.match(/^(https?:|git@github)/);
170183
if (unsupportedPrefix)

packages/plugin-essentials/sources/suggestUtils.ts

Lines changed: 50 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import {Cache, DescriptorHash, Descriptor, Ident, Locator, Manifest, Project, ThrowReport, Workspace, FetchOptions, ResolveOptions, Configuration} from '@yarnpkg/core';
2-
import {formatUtils, structUtils, semverUtils} from '@yarnpkg/core';
3-
import {PortablePath, ppath, xfs} from '@yarnpkg/fslib';
4-
import semver from 'semver';
1+
import {Cache, DescriptorHash, Descriptor, Ident, Locator, Manifest, Project, ThrowReport, Workspace, FetchOptions, ResolveOptions, Configuration, TAG_REGEXP} from '@yarnpkg/core';
2+
import {formatUtils, structUtils, semverUtils} from '@yarnpkg/core';
3+
import {PortablePath, ppath, xfs} from '@yarnpkg/fslib';
4+
import semver from 'semver';
55

66
const WORKSPACE_PROTOCOL = `workspace:`;
77

@@ -204,17 +204,56 @@ export async function extractDescriptorFromPath(path: PortablePath, {cwd, worksp
204204
});
205205
}
206206

207+
type InferenceParameters = {
208+
type: `fixed`;
209+
range: string;
210+
} | {
211+
type: `resolve`;
212+
range: string;
213+
};
214+
215+
216+
function extractInferenceParametersFromRequest(request: Descriptor): InferenceParameters {
217+
if (request.range === `unknown`)
218+
return {type: `resolve`, range: `latest`};
219+
220+
if (semverUtils.validRange(request.range))
221+
return {type: `fixed`, range: request.range};
222+
223+
if (TAG_REGEXP.test(request.range))
224+
return {type: `resolve`, range: request.range};
225+
226+
const registryProtocol = request.range.match(/^(?:jsr:|npm:)(.*)/);
227+
if (!registryProtocol)
228+
return {type: `fixed`, range: request.range};
229+
230+
let [, range] = registryProtocol;
231+
232+
// Try to handle the case of "foo@jsr:foo@latest", because otherwise there
233+
// would be no way to cleanly add a package from a 3rd-party registry using
234+
// a tag (since foo@jsr:latest would refer to "the package named 'latest'").
235+
const selfRegistryPrefix = `${structUtils.stringifyIdent(request)}@`;
236+
if (range.startsWith(selfRegistryPrefix))
237+
range = range.slice(selfRegistryPrefix.length);
238+
239+
if (semverUtils.validRange(range))
240+
return {type: `fixed`, range: request.range};
241+
242+
if (TAG_REGEXP.test(range))
243+
return {type: `resolve`, range: request.range};
244+
245+
return {type: `fixed`, range: request.range};
246+
}
247+
207248
export async function getSuggestedDescriptors(request: Descriptor, {project, workspace, cache, target, fixed, modifier, strategies, maxResults = Infinity}: {project: Project, workspace: Workspace, cache: Cache, target: Target, fixed: boolean, modifier: Modifier, strategies: Array<Strategy>, maxResults?: number}): Promise<Results> {
208249
if (!(maxResults >= 0))
209250
throw new Error(`Invalid maxResults (${maxResults})`);
210251

211-
const [requestRange, requestTag] = request.range !== `unknown`
212-
? fixed || semverUtils.validRange(request.range) || !request.range.match(/^[a-z0-9._-]+$/i)
213-
? [request.range, `latest`]
214-
: [`unknown`, request.range]
215-
: [`unknown`, `latest`];
252+
const inference = !fixed || request.range === `unknown`
253+
? extractInferenceParametersFromRequest(request)
254+
: {type: `fixed`, range: request.range};
216255

217-
if (requestRange !== `unknown`) {
256+
if (inference.type === `fixed`) {
218257
return {
219258
suggestions: [{
220259
descriptor: request,
@@ -332,7 +371,7 @@ export async function getSuggestedDescriptors(request: Descriptor, {project, wor
332371
reason: formatUtils.pretty(project.configuration, `(unavailable because enableNetwork is toggled off)`, `grey`),
333372
});
334373
} else {
335-
const latest = await fetchDescriptorFrom(request, requestTag, {project, cache, workspace, modifier});
374+
const latest = await fetchDescriptorFrom(request, inference.range, {project, cache, workspace, modifier});
336375
if (latest) {
337376
suggested.push({
338377
descriptor: latest,
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import {Fetcher, FetchOptions, Locator} from '@yarnpkg/core';
2+
3+
import {JSR_PROTOCOL} from './constants';
4+
import {convertLocatorFromJsrToNpm} from './helpers';
5+
6+
export class JsrFetcher implements Fetcher {
7+
supports(locator: Locator, opts: FetchOptions) {
8+
return locator.reference.startsWith(JSR_PROTOCOL);
9+
}
10+
11+
getLocalPath(locator: Locator, opts: FetchOptions) {
12+
const nextLocator = convertLocatorFromJsrToNpm(locator);
13+
14+
return opts.fetcher.getLocalPath(nextLocator, opts);
15+
}
16+
17+
fetch(locator: Locator, opts: FetchOptions) {
18+
const nextLocator = convertLocatorFromJsrToNpm(locator);
19+
20+
return opts.fetcher.fetch(nextLocator, opts);
21+
}
22+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import {Locator, MinimalResolveOptions, Package, ResolveOptions, Resolver} from '@yarnpkg/core';
2+
import {Descriptor} from '@yarnpkg/core';
3+
4+
import {JSR_PROTOCOL} from './constants';
5+
import {convertDescriptorFromJsrToNpm, convertLocatorFromJsrToNpm, convertLocatorFromNpmToJsr} from './helpers';
6+
7+
export class JsrResolver implements Resolver {
8+
supportsDescriptor(descriptor: Descriptor, opts: MinimalResolveOptions) {
9+
if (!descriptor.range.startsWith(JSR_PROTOCOL))
10+
return false;
11+
12+
return true;
13+
}
14+
15+
supportsLocator(locator: Locator, opts: MinimalResolveOptions) {
16+
if (!locator.reference.startsWith(JSR_PROTOCOL))
17+
return false;
18+
19+
return true;
20+
}
21+
22+
shouldPersistResolution(locator: Locator, opts: MinimalResolveOptions) {
23+
const nextLocator = convertLocatorFromJsrToNpm(locator);
24+
25+
return opts.resolver.shouldPersistResolution(nextLocator, opts);
26+
}
27+
28+
bindDescriptor(descriptor: Descriptor, fromLocator: Locator, opts: MinimalResolveOptions) {
29+
return descriptor;
30+
}
31+
32+
getResolutionDependencies(descriptor: Descriptor, opts: MinimalResolveOptions) {
33+
// This is a hack. Let me explain:
34+
//
35+
// Imagine we run `yarn add foo@jsr:^1.0.0`. We want to have `"foo": "jsr:1.2.3"` in our lockfile. For
36+
// that to happen, suggestUtils need our resolver to return "foo@jsr:1.2.3" as locator; if we were to
37+
// return "foo@npm:@jsr/[email protected]" instead, then it'd be unable to store it as-is (unless we hardcoded
38+
// this behavior).
39+
//
40+
// However, if we return "foo@jsr:1.2.3", then we need to also have a fetcher. Since we don't want to
41+
// recode the whole fetcher, we just convert our jsr locator into a npm one and recursively call the
42+
// fetcher. This works, but with a caveat: since we are "publicly" resolving to foo@jsr:1.2.3 locator
43+
// rather than foo@npm:@jsr/[email protected], then foo@npm:@jsr/[email protected] will not be in the lockfile. And it's
44+
// in the lockfile that we keep the checksums, so there would be no checksum registration for this package.
45+
//
46+
// To avoid this, we register a resolution dependency on the converted npm descriptor. This forces it
47+
// to be present in the lockfile.
48+
//
49+
// It's not ideal because nothing guarantees that foo@jsr:^1.0.0 will be locked to the same version as
50+
// foo@npm:@jsr/[email protected] (I think it should be the case except if the user manually modifies the
51+
// lockfile), but it's the best I can think of for now (I think we'll be able to fix this in the next
52+
// major redesign).
53+
//
54+
// It's almost like we'd need to have locator dependencies.
55+
//
56+
return {inner: convertDescriptorFromJsrToNpm(descriptor)};
57+
}
58+
59+
async getCandidates(descriptor: Descriptor, dependencies: Record<string, Package>, opts: ResolveOptions) {
60+
const nextDescriptor = opts.project.configuration.normalizeDependency(convertDescriptorFromJsrToNpm(descriptor));
61+
const candidates = await opts.resolver.getCandidates(nextDescriptor, dependencies, opts);
62+
63+
return candidates.map(candidate => {
64+
return convertLocatorFromNpmToJsr(candidate);
65+
});
66+
}
67+
68+
async getSatisfying(descriptor: Descriptor, dependencies: Record<string, Package>, locators: Array<Locator>, opts: ResolveOptions) {
69+
const nextDescriptor = opts.project.configuration.normalizeDependency(convertDescriptorFromJsrToNpm(descriptor));
70+
71+
return opts.resolver.getSatisfying(nextDescriptor, dependencies, locators, opts);
72+
}
73+
74+
async resolve(locator: Locator, opts: ResolveOptions) {
75+
const nextLocator = convertLocatorFromJsrToNpm(locator);
76+
const resolved = await opts.resolver.resolve(nextLocator, opts);
77+
78+
return {...resolved, ...convertLocatorFromNpmToJsr(resolved)};
79+
}
80+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const JSR_PROTOCOL = `jsr:`;
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import {Descriptor, Locator, semverUtils} from '@yarnpkg/core';
2+
import {structUtils} from '@yarnpkg/core';
3+
4+
export function convertDescriptorFromJsrToNpm(dependency: Descriptor) {
5+
const rangeWithoutProtocol = dependency.range.slice(4);
6+
7+
if (semverUtils.validRange(rangeWithoutProtocol))
8+
return structUtils.makeDescriptor(dependency, `npm:${structUtils.stringifyIdent(structUtils.wrapIdentIntoScope(dependency, `jsr`))}@${rangeWithoutProtocol}`);
9+
10+
const parsedRange = structUtils.tryParseDescriptor(rangeWithoutProtocol, true);
11+
if (parsedRange !== null)
12+
return structUtils.makeDescriptor(dependency, `npm:${structUtils.stringifyIdent(structUtils.wrapIdentIntoScope(parsedRange, `jsr`))}@${parsedRange.range}`);
13+
14+
throw new Error(`Invalid range: ${dependency.range}`);
15+
}
16+
17+
export function convertLocatorFromJsrToNpm(locator: Locator) {
18+
return structUtils.makeLocator(structUtils.wrapIdentIntoScope(locator, `jsr`), `npm:${locator.reference.slice(4)}`);
19+
}
20+
21+
export function convertLocatorFromNpmToJsr(locator: Locator) {
22+
return structUtils.makeLocator(structUtils.unwrapIdentFromScope(locator, `jsr`), `jsr:${locator.reference.slice(4)}`);
23+
}

packages/plugin-jsr/sources/index.ts

Lines changed: 12 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,9 @@
1-
import {Descriptor, Locator, Plugin, Project, ResolveOptions, Resolver, Workspace} from '@yarnpkg/core';
2-
import {structUtils, semverUtils} from '@yarnpkg/core';
1+
import {Plugin, Workspace} from '@yarnpkg/core';
2+
import {structUtils} from '@yarnpkg/core';
33

4-
function normalizeJsrDependency(dependency: Descriptor) {
5-
if (semverUtils.validRange(dependency.range.slice(4)))
6-
return structUtils.makeDescriptor(dependency, `npm:${structUtils.wrapIdentIntoScope(dependency, `jsr`)}@${dependency.range.slice(4)}`);
7-
8-
const parsedRange = structUtils.tryParseDescriptor(dependency.range.slice(4), true);
9-
if (parsedRange !== null)
10-
return structUtils.makeDescriptor(dependency, `npm:${structUtils.wrapIdentIntoScope(parsedRange, `jsr`)}@${parsedRange.range}`);
11-
12-
13-
return dependency;
14-
}
15-
16-
function reduceDependency(dependency: Descriptor, project: Project, locator: Locator, initialDependency: Descriptor, {resolver, resolveOptions}: {resolver: Resolver, resolveOptions: ResolveOptions}) {
17-
return dependency.range.startsWith(`jsr:`)
18-
? normalizeJsrDependency(dependency)
19-
: dependency;
20-
}
4+
import {JsrFetcher} from './JsrFetcher';
5+
import {JsrResolver} from './JsrResolver';
6+
import {convertDescriptorFromJsrToNpm} from './helpers';
217

228
const DEPENDENCY_TYPES = [`dependencies`, `devDependencies`, `peerDependencies`];
239

@@ -27,7 +13,7 @@ function beforeWorkspacePacking(workspace: Workspace, rawManifest: any) {
2713
if (!descriptor.range.startsWith(`jsr:`))
2814
continue;
2915

30-
const normalizedDescriptor = normalizeJsrDependency(descriptor);
16+
const normalizedDescriptor = convertDescriptorFromJsrToNpm(descriptor);
3117

3218
// Ensure optional dependencies are handled as well
3319
const identDescriptor = dependencyType === `dependencies`
@@ -45,9 +31,14 @@ function beforeWorkspacePacking(workspace: Workspace, rawManifest: any) {
4531

4632
const plugin: Plugin = {
4733
hooks: {
48-
reduceDependency,
4934
beforeWorkspacePacking,
5035
},
36+
resolvers: [
37+
JsrResolver,
38+
],
39+
fetchers: [
40+
JsrFetcher,
41+
],
5142
};
5243

5344
// eslint-disable-next-line arca/no-default-export

packages/yarnpkg-core/sources/structUtils.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -641,12 +641,26 @@ export function stringifyIdent(ident: Ident) {
641641

642642
export function wrapIdentIntoScope(ident: Ident, scope: string) {
643643
if (ident.scope) {
644-
return `@${scope}/${ident.scope}__${ident.name}`;
644+
return structUtils.makeIdent(scope, `${ident.scope}__${ident.name}`);
645645
} else {
646-
return `@${scope}/${ident.name}`;
646+
return structUtils.makeIdent(scope, ident.name);
647647
}
648648
}
649649

650+
export function unwrapIdentFromScope(ident: Ident, scope: string) {
651+
if (ident.scope !== scope)
652+
return ident;
653+
654+
const underscoreUnderscore = ident.name.indexOf(`__`);
655+
if (underscoreUnderscore === -1)
656+
return makeIdent(null, ident.name);
657+
658+
const innerScope = ident.name.slice(0, underscoreUnderscore);
659+
const innerName = ident.name.slice(underscoreUnderscore + 2);
660+
661+
return makeIdent(innerScope, innerName);
662+
}
663+
650664
/**
651665
* Returns a string from a descriptor (eg. `@types/lodash@^1.0.0`).
652666
*/

0 commit comments

Comments
 (0)