OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / routes / npm-registry.js

267 lines · javascript ref:
/**
 * 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)
  }
}