/**
* Private npm Registry Routes
*
* Serves npm-compatible package metadata and tarballs from a deps repository.
* Enables pnpm/npm to install private @holepunchto packages from pear-git.
*
* Endpoints:
* - GET /npm/@:scope/:name → package metadata JSON
* - GET /npm/@:scope/:name/-/:tarball → package tarball (tar.gz)
* - GET /npm/:name → unscoped package metadata
* - GET /npm/:name/-/:tarball → unscoped package tarball
*
* Client config (.npmrc):
* @holepunchto:registry=http://localhost:5005/git/npm/
*/
import { spawn } from 'bare-subprocess'
import path from 'bare-path'
import crypto from 'bare-crypto'
/**
* Register npm registry routes
* @param {Router} router
* @param {Object} opts - { gitReposPath }
*/
export function registerNpmRegistryRoutes (router, opts = {}) {
const gitReposPath = opts.gitReposPath
// Scoped packages: @scope/name (when client sends decoded URL)
router.get('/npm/@:scope/:name', (req, res, server) => {
handlePackageMetadata(req, res, server, `@${req.params.scope}/${req.params.name}`, gitReposPath)
})
// Scoped tarball: @scope/name/-/tarball
router.get('/npm/@:scope/:name/-/:tarball', (req, res, server) => {
handlePackageTarball(req, res, server, `@${req.params.scope}/${req.params.name}`, req.params.tarball, gitReposPath)
})
// Scoped packages: npm/pnpm URL-encode the scope as %2f (e.g., @holepunchto%2fname)
// This arrives as a single :name segment — decode and dispatch
router.get('/npm/:name', (req, res, server) => {
const name = decodeURIComponent(req.params.name)
if (name.startsWith('@') && name.includes('/')) {
handlePackageMetadata(req, res, server, name, gitReposPath)
return
}
handlePackageMetadata(req, res, server, name, gitReposPath)
})
// Tarball for URL-encoded scoped packages or unscoped
router.get('/npm/:name/-/:tarball', (req, res, server) => {
const name = decodeURIComponent(req.params.name)
handlePackageTarball(req, res, server, name, req.params.tarball, gitReposPath)
})
}
/**
* Resolve where a package lives — returns { repoPath, treePath } or null.
*
* Strategy:
* 1. Try holepunchto-deps bare repo (scoped packages live here as directories)
* 2. Fall back to the package's own bare repo (e.g., keet-daemon.git)
*/
async function resolvePackage (packageName, gitReposPath) {
const depsRepo = path.join(gitReposPath, 'holepunchto-deps.git')
// Try deps repo first (packages are subdirectories)
try {
await gitShow(depsRepo, 'HEAD', `${packageName}/package.json`)
return { repoPath: depsRepo, treePath: packageName }
} catch {}
// Fall back to package's own bare repo (package.json at root)
const ownRepo = path.join(gitReposPath, packageName + '.git')
try {
await gitShow(ownRepo, 'HEAD', 'package.json')
return { repoPath: ownRepo, treePath: '' }
} catch {}
return null
}
/**
* Run a git command on a bare repo and return stdout as a buffer
*/
function gitShow (repoPath, ref, filePath) {
return new Promise((resolve, reject) => {
const proc = spawn('git', ['--git-dir', repoPath, 'show', `${ref}:${filePath}`])
const chunks = []
proc.stdout.on('data', chunk => chunks.push(chunk))
proc.stderr.on('data', () => {}) // ignore stderr
proc.on('exit', code => {
if (code !== 0) reject(new Error(`git show failed (exit ${code})`))
else resolve(Buffer.concat(chunks))
})
})
}
/**
* Run git archive on a bare repo and stream the tarball
*/
function gitArchive (repoPath, ref, prefix, res) {
const proc = spawn('git', [
'--git-dir', repoPath, 'archive',
'--format=tar.gz',
`--prefix=package/`,
`${ref}:${prefix}/`
])
res.setHeader('Content-Type', 'application/octet-stream')
proc.stdout.on('data', chunk => res.write(chunk))
proc.stderr.on('data', () => {})
proc.on('exit', code => {
if (code !== 0 && !res.headersSent) {
res.statusCode = 500
res.end(JSON.stringify({ error: 'Failed to create tarball' }))
} else {
res.end()
}
})
}
/**
* Run git archive on a bare repo root and stream the tarball
*/
function gitArchiveRoot (repoPath, ref, res) {
const proc = spawn('git', [
'--git-dir', repoPath, 'archive',
'--format=tar.gz',
'--prefix=package/',
ref
])
res.setHeader('Content-Type', 'application/octet-stream')
proc.stdout.on('data', chunk => res.write(chunk))
proc.stderr.on('data', () => {})
proc.on('exit', code => {
if (code !== 0 && !res.headersSent) {
res.statusCode = 500
res.end(JSON.stringify({ error: 'Failed to create tarball' }))
} else {
res.end()
}
})
}
/**
* Run git archive and return the tarball as a buffer (for shasum computation)
*/
function gitArchiveBuffer (repoPath, ref, treePath) {
return new Promise((resolve, reject) => {
const args = ['--git-dir', repoPath, 'archive', '--format=tar.gz', '--prefix=package/']
if (treePath) {
args.push(`${ref}:${treePath}/`)
} else {
args.push(ref)
}
const proc = spawn('git', args)
const chunks = []
proc.stdout.on('data', chunk => chunks.push(chunk))
proc.stderr.on('data', () => {})
proc.on('exit', code => {
if (code !== 0) reject(new Error(`git archive failed (exit ${code})`))
else resolve(Buffer.concat(chunks))
})
})
}
/**
* Serve package metadata (npm registry format)
*/
async function handlePackageMetadata (req, res, server, packageName, gitReposPath) {
const resolved = await resolvePackage(packageName, gitReposPath)
if (!resolved) {
res.statusCode = 404
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ error: 'Package not found: ' + packageName }))
return
}
const { repoPath, treePath } = resolved
const pkgJsonPath = treePath ? `${treePath}/package.json` : 'package.json'
let pkgJson
try {
const raw = await gitShow(repoPath, 'HEAD', pkgJsonPath)
pkgJson = JSON.parse(raw.toString())
} catch {
res.statusCode = 404
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ error: 'Package not found: ' + packageName }))
return
}
const version = pkgJson.version
const tarballName = `${pkgJson.name.replace('/', '-').replace('@', '')}-${version}.tgz`
// Build tarball URL using the request host so the client can reach it
const { pathPrefix } = server.config
const prefix = pathPrefix || ''
const baseUrl = `http://${req.headers.host}${prefix}`
const tarballUrl = `${baseUrl}/npm/${packageName}/-/${tarballName}`
// Compute shasum from the actual tarball bytes so pnpm integrity check passes
let shasum = ''
try {
const tarballBuf = await gitArchiveBuffer(repoPath, 'HEAD', treePath)
shasum = crypto.createHash('sha1').update(tarballBuf).digest('hex')
} catch {
// Fall back to empty — pnpm will skip integrity check
}
// npm registry metadata format
const metadata = {
name: pkgJson.name,
description: pkgJson.description || '',
'dist-tags': {
latest: version
},
versions: {
[version]: {
name: pkgJson.name,
version,
description: pkgJson.description || '',
main: pkgJson.main || 'index.js',
dependencies: pkgJson.dependencies || {},
optionalDependencies: pkgJson.optionalDependencies || {},
peerDependencies: pkgJson.peerDependencies || {},
imports: pkgJson.imports || undefined,
dist: {
tarball: tarballUrl,
shasum
}
}
}
}
res.statusCode = 200
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify(metadata))
}
/**
* Serve package tarball
*/
async function handlePackageTarball (req, res, server, packageName, tarballName, gitReposPath) {
const resolved = await resolvePackage(packageName, gitReposPath)
if (!resolved) {
res.statusCode = 404
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({ error: 'Package not found: ' + packageName }))
return
}
const { repoPath, treePath } = resolved
// Stream tarball via git archive
// --prefix=package/ makes the tarball root "package/" which npm expects
if (treePath) {
// Subdirectory in deps repo: archive that subtree
gitArchive(repoPath, 'HEAD', treePath, res)
} else {
// Own repo: archive entire repo root
gitArchiveRoot(repoPath, 'HEAD', res)
}
}