From 4353fc151ef4fa58e1b8b354bc175b113353f791 Mon Sep 17 00:00:00 2001 From: Eser Ozvataf Date: Wed, 22 Oct 2025 05:02:58 +0300 Subject: [PATCH] feat: add JSR protocol support for package specifiers --- node_modules/npm-package-arg/lib/npa.js | 65 +++++ test/lib/commands/install-jsr.js | 228 ++++++++++++++++++ .../config/lib/definitions/definitions.js | 11 + 3 files changed, 304 insertions(+) create mode 100644 test/lib/commands/install-jsr.js diff --git a/node_modules/npm-package-arg/lib/npa.js b/node_modules/npm-package-arg/lib/npa.js index 50121b99efbe3..140b4c0f98875 100644 --- a/node_modules/npm-package-arg/lib/npa.js +++ b/node_modules/npm-package-arg/lib/npa.js @@ -78,6 +78,13 @@ function isAliasSpec (spec) { return spec.toLowerCase().startsWith('npm:') } +function isJsrSpec (spec) { + if (!spec) { + return false + } + return spec.toLowerCase().startsWith('jsr:') +} + function resolve (name, spec, where, arg) { const res = new Result({ raw: arg, @@ -98,6 +105,8 @@ function resolve (name, spec, where, arg) { return fromFile(res, where) } else if (isAliasSpec(spec)) { return fromAlias(res, where) + } else if (isJsrSpec(spec)) { + return fromJsr(res, where) } const hosted = HostedGit.fromUrl(spec, { @@ -453,6 +462,62 @@ function fromAlias (res, where) { return res } +function fromJsr (res, where) { + // Remove 'jsr:' prefix + const jsrSpec = res.rawSpec.substr(4) + + // Parse the JSR specifier to extract name and version + // JSR format: @scope/name or @scope/name@version + const nameEnd = jsrSpec.indexOf('@', 1) // Skip the leading @ in @scope + const jsrName = nameEnd > 0 ? jsrSpec.slice(0, nameEnd) : jsrSpec + const versionSpec = nameEnd > 0 ? jsrSpec.slice(nameEnd + 1) : '' + + // Validate that JSR package is scoped + if (!jsrName.startsWith('@') || !jsrName.includes('/')) { + throw new Error(`JSR packages must be scoped (e.g., jsr:@scope/name): ${res.raw}`) + } + + // Validate the package name + const valid = validatePackageName(jsrName) + if (!valid.validForOldPackages) { + throw invalidPackageName(jsrName, valid, res.raw) + } + + // Transform @scope/name to @jsr/scope__name + // Extract scope and package name + const scopeEnd = jsrName.indexOf('/') + const scope = jsrName.slice(1, scopeEnd) // Remove leading @ from scope + const packageName = jsrName.slice(scopeEnd + 1) + const transformedName = `@jsr/${scope}__${packageName}` + + // Set the transformed name + res.setName(transformedName) + res.registry = true + + // Preserve the original JSR spec for saving + res.saveSpec = `jsr:${jsrName}${versionSpec ? '@' + versionSpec : ''}` + + // Determine the type based on version specifier + const spec = versionSpec || '*' + res.rawSpec = spec + res.fetchSpec = spec + + const version = semver.valid(spec, true) + const range = semver.validRange(spec, true) + if (version) { + res.type = 'version' + } else if (range) { + res.type = 'range' + } else { + if (encodeURIComponent(spec) !== spec) { + throw invalidTagName(spec, res.raw) + } + res.type = 'tag' + } + + return res +} + function fromRegistry (res) { res.registry = true const spec = res.rawSpec.trim() diff --git a/test/lib/commands/install-jsr.js b/test/lib/commands/install-jsr.js new file mode 100644 index 0000000000000..880d5af2eb937 --- /dev/null +++ b/test/lib/commands/install-jsr.js @@ -0,0 +1,228 @@ +const { + cleanCwd, + cleanTime, + cleanDate, + cleanPackumentCache, +} = require('../../fixtures/clean-snapshot.js') + +const { resolve } = require('node:path') +const { readFileSync } = require('node:fs') +const path = require('node:path') +const t = require('tap') + +t.cleanSnapshot = (str) => cleanPackumentCache(cleanDate(cleanTime(cleanCwd(str)))) + +const { + loadNpmWithRegistry: loadMockNpm, +} = require('../../fixtures/mock-npm') + +const packageJson = { + name: '@npmcli/test-jsr-package', + version: '1.0.0', +} + +const jsrPackage = { + 'package.json': JSON.stringify({ + name: '@jsr/std__testing', + version: '1.0.0', + description: 'Testing utilities from JSR', + }), + 'index.js': 'module.exports = {}', +} + +const readPackageJson = (prefix) => + JSON.parse(readFileSync(resolve(prefix, 'package.json'), 'utf8')) + +t.test('JSR protocol support', async t => { + await t.test('install JSR package without version', async t => { + const { npm, registry } = await loadMockNpm(t, { + config: { + audit: false, + // Configure @jsr:registry to use the mock registry for testing + '@jsr:registry': 'https://registry.npmjs.org/', + }, + prefixDir: { + 'package.json': JSON.stringify(packageJson), + 'jsr-std-testing': jsrPackage, + }, + }) + + // The JSR package @std/testing will be transformed to @jsr/std__testing + const manifest = registry.manifest({ + name: '@jsr/std__testing', + }) + await registry.package({ manifest }) + await registry.tarball({ + manifest: manifest.versions['1.0.0'], + tarball: path.join(npm.prefix, 'jsr-std-testing'), + }) + + await npm.exec('install', ['jsr:@std/testing']) + + const pkg = readPackageJson(npm.prefix) + t.ok(pkg.dependencies, 'has dependencies') + t.match(pkg.dependencies, { + '@jsr/std__testing': '^1.0.0', + }, 'JSR package was added to dependencies with transformed name') + }) + + await t.test('install JSR package with specific version', async t => { + const jsrPackageV110 = { + 'package.json': JSON.stringify({ + name: '@jsr/std__testing', + version: '1.1.0', + }), + 'index.js': 'module.exports = {}', + } + + const { npm, registry } = await loadMockNpm(t, { + config: { + audit: false, + '@jsr:registry': 'https://registry.npmjs.org/', + }, + prefixDir: { + 'package.json': JSON.stringify(packageJson), + 'jsr-std-testing': jsrPackageV110, + }, + }) + + const manifest = registry.manifest({ + name: '@jsr/std__testing', + versions: ['1.0.0', '1.1.0', '2.0.0'], + }) + await registry.package({ manifest }) + await registry.tarball({ + manifest: manifest.versions['1.1.0'], + tarball: path.join(npm.prefix, 'jsr-std-testing'), + }) + + await npm.exec('install', ['jsr:@std/testing@1.1.0']) + + const pkg = readPackageJson(npm.prefix) + t.match(pkg.dependencies, { + '@jsr/std__testing': '1.1.0', + }, 'JSR package with specific version was installed') + }) + + await t.test('install JSR package with version range', async t => { + const jsrPackageV110 = { + 'package.json': JSON.stringify({ + name: '@jsr/std__testing', + version: '1.1.0', + }), + 'index.js': 'module.exports = {}', + } + + const { npm, registry } = await loadMockNpm(t, { + config: { + audit: false, + '@jsr:registry': 'https://registry.npmjs.org/', + }, + prefixDir: { + 'package.json': JSON.stringify(packageJson), + 'jsr-std-testing': jsrPackageV110, + }, + }) + + const manifest = registry.manifest({ + name: '@jsr/std__testing', + versions: ['1.0.0', '1.1.0', '2.0.0'], + }) + await registry.package({ manifest }) + await registry.tarball({ + manifest: manifest.versions['1.1.0'], + tarball: path.join(npm.prefix, 'jsr-std-testing'), + }) + + await npm.exec('install', ['jsr:@std/testing@^1.0.0']) + + const pkg = readPackageJson(npm.prefix) + t.match(pkg.dependencies, { + '@jsr/std__testing': '^1.1.0', + }, 'JSR package with range was installed and resolved to latest matching version') + }) + + await t.test('install JSR package with alias', async t => { + const { npm, registry } = await loadMockNpm(t, { + config: { + audit: false, + '@jsr:registry': 'https://registry.npmjs.org/', + }, + prefixDir: { + 'package.json': JSON.stringify(packageJson), + 'jsr-std-testing': jsrPackage, + }, + }) + + const manifest = registry.manifest({ + name: '@jsr/std__testing', + }) + await registry.package({ manifest }) + await registry.tarball({ + manifest: manifest.versions['1.0.0'], + tarball: path.join(npm.prefix, 'jsr-std-testing'), + }) + + await npm.exec('install', ['testing@jsr:@std/testing']) + + const pkg = readPackageJson(npm.prefix) + // When using alias syntax, npm should save it as: "testing": "jsr:@std/testing@^1.0.0" + t.ok(pkg.dependencies.testing || pkg.dependencies['@jsr/std__testing'], 'JSR package was added') + if (pkg.dependencies.testing) { + t.match(pkg.dependencies.testing, /jsr:@std\/testing/, 'alias points to JSR package') + } + }) + + await t.test('JSR package validation', async t => { + await t.test('reject unscoped JSR packages', async t => { + const { npm } = await loadMockNpm(t, { + config: { + audit: false, + }, + prefixDir: { + 'package.json': JSON.stringify(packageJson), + }, + }) + + await t.rejects( + npm.exec('install', ['jsr:unscoped-package']), + /JSR packages must be scoped/, + 'rejects unscoped JSR package' + ) + }) + + await t.test('reject JSR package without slash', async t => { + const { npm } = await loadMockNpm(t, { + config: { + audit: false, + }, + prefixDir: { + 'package.json': JSON.stringify(packageJson), + }, + }) + + await t.rejects( + npm.exec('install', ['jsr:@scopeonly']), + /JSR packages must be scoped/, + 'rejects JSR package without name part' + ) + }) + }) + + await t.test('JSR registry configuration', async t => { + const { npm } = await loadMockNpm(t, { + config: { + audit: false, + }, + prefixDir: { + 'package.json': JSON.stringify(packageJson), + }, + }) + + t.equal( + npm.config.get('@jsr:registry'), + 'https://npm.jsr.io/', + '@jsr:registry is configured with default value' + ) + }) +}) diff --git a/workspaces/config/lib/definitions/definitions.js b/workspaces/config/lib/definitions/definitions.js index 4a35830b46a3c..28abbc1d57087 100644 --- a/workspaces/config/lib/definitions/definitions.js +++ b/workspaces/config/lib/definitions/definitions.js @@ -1649,6 +1649,17 @@ const definitions = { `, flatten, }), + '@jsr:registry': new Definition('@jsr:registry', { + default: 'https://npm.jsr.io/', + type: url, + description: ` + The base URL of the JSR (JavaScript Registry) npm-compatible layer. + Used when installing packages with the jsr: protocol. + `, + flatten: (key, obj, flatOptions) => { + flatOptions[key] = obj[key] + }, + }), 'replace-registry-host': new Definition('replace-registry-host', { default: 'npmjs', hint: ' | hostname',