From df44705b4a7b4590531536449fcfd81de01bc36b Mon Sep 17 00:00:00 2001 From: Luke Karrys Date: Tue, 30 Apr 2024 17:13:42 -0700 Subject: [PATCH] feat!: use AbortSignal instead of EventEmitter for opener BREAKING CHANGE: the opener function will now receive an object with an abort signal which can be used to listen for the abort event intead of an event emitter --- lib/index.js | 307 +++++++++++++++++++++++--------------------------- test/index.js | 7 +- 2 files changed, 141 insertions(+), 173 deletions(-) diff --git a/lib/index.js b/lib/index.js index e5b5dd0..94ab53d 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,37 +1,35 @@ -'use strict' - +const { URL } = require('node:url') +const timers = require('node:timers/promises') +const os = require('node:os') const fetch = require('npm-registry-fetch') const { HttpErrorBase } = require('npm-registry-fetch/lib/errors') -const EventEmitter = require('events') -const os = require('os') -const { URL } = require('url') const { log } = require('proc-log') // try loginWeb, catch the "not supported" message and fall back to couch -const login = (opener, prompter, opts = {}) => { - const { creds } = opts - return loginWeb(opener, opts).catch(er => { +const login = async (opener, prompter, opts = {}) => { + try { + return await loginWeb(opener, opts) + } catch (er) { if (er instanceof WebLoginNotSupported) { - log.verbose('web login not supported, trying couch') - return prompter(creds) - .then(data => loginCouch(data.username, data.password, opts)) - } else { - throw er + log.verbose('web login', 'not supported, trying couch') + const { username, password } = await prompter(opts.creds) + return loginCouch(username, password, opts) } - }) + throw er + } } -const adduser = (opener, prompter, opts = {}) => { - const { creds } = opts - return adduserWeb(opener, opts).catch(er => { +const adduser = async (opener, prompter, opts = {}) => { + try { + return await adduserWeb(opener, opts) + } catch (er) { if (er instanceof WebLoginNotSupported) { - log.verbose('web adduser not supported, trying couch') - return prompter(creds) - .then(data => adduserCouch(data.username, data.email, data.password, opts)) - } else { - throw er + log.verbose('web adduser', 'not supported, trying couch') + const { username, email, password } = await prompter(opts.creds) + return adduserCouch(username, email, password, opts) } - }) + throw er + } } const adduserWeb = (opener, opts = {}) => { @@ -47,88 +45,89 @@ const loginWeb = (opener, opts = {}) => { const isValidUrl = u => { try { return /^https?:$/.test(new URL(u).protocol) - } catch (er) { + } catch { return false } } -const webAuth = (opener, opts, body) => { - const { hostname } = opts - body.hostname = hostname || os.hostname() - const target = '/-/v1/login' - const doneEmitter = new EventEmitter() - return fetch(target, { - ...opts, - method: 'POST', - body, - }).then(res => { - return Promise.all([res, res.json()]) - }).then(([res, content]) => { - const { doneUrl, loginUrl } = content +const webAuth = async (opener, opts, body) => { + const abortController = new AbortController() + try { + const res = await fetch('/-/v1/login', { + ...opts, + method: 'POST', + body: { + ...body, + hostname: opts.hostname || os.hostname(), + }, + }) + + const content = await res.json() log.verbose('web auth', 'got response', content) + + const { doneUrl, loginUrl } = content if (!isValidUrl(doneUrl) || !isValidUrl(loginUrl)) { throw new WebLoginInvalidResponse('POST', res, content) } - return content - }).then(({ doneUrl, loginUrl }) => { - log.verbose('web auth', 'opening url pair') - const openPromise = opener(loginUrl, doneEmitter) - const webAuthCheckPromise = webAuthCheckLogin(doneUrl, { ...opts, cache: false }) - .then(authResult => { + log.verbose('web auth', 'opening url pair') + return await Promise.all([ + opener(loginUrl, { signal: abortController.signal }), + webAuthCheckLogin(doneUrl, { ...opts, cache: false }).then((r) => { log.verbose('web auth', 'done-check finished') - - // cancel open prompt if it's present - doneEmitter.emit('abort') - - return authResult - }) - - return Promise.all([openPromise, webAuthCheckPromise]).then( - // pick the auth result and pass it along - ([, authResult]) => authResult - ) - }).catch(er => { - // cancel open prompt if it's present - doneEmitter.emit('abort') - + abortController.abort() + return r + }), + ]).then(([, authResult]) => authResult) + } catch (er) { + abortController.abort() if ((er.statusCode >= 400 && er.statusCode <= 499) || er.statusCode === 500) { throw new WebLoginNotSupported('POST', { status: er.statusCode, headers: { raw: () => er.headers }, }, er.body) - } else { - throw er } - }) + throw er + } } -const webAuthCheckLogin = (doneUrl, opts) => { - return fetch(doneUrl, opts).then(res => { - return Promise.all([res, res.json()]) - }).then(([res, content]) => { - if (res.status === 200) { - if (!content.token) { - throw new WebLoginInvalidResponse('GET', res, content) - } else { - return content - } - } else if (res.status === 202) { - const retry = +res.headers.get('retry-after') * 1000 - if (retry > 0) { - return sleep(retry).then(() => webAuthCheckLogin(doneUrl, opts)) - } else { - return webAuthCheckLogin(doneUrl, opts) - } - } else { +const webAuthCheckLogin = async (doneUrl, opts) => { + const res = await fetch(doneUrl, opts) + const content = await res.json() + + if (res.status === 200) { + if (!content.token) { throw new WebLoginInvalidResponse('GET', res, content) } + return content + } + + if (res.status === 202) { + const retry = +res.headers.get('retry-after') * 1000 + if (retry > 0) { + await timers.setTimeout(retry) + } + return webAuthCheckLogin(doneUrl, opts) + } + + throw new WebLoginInvalidResponse('GET', res, content) +} + +const couchEndpoint = (username) => `/-/user/org.couchdb.user:${encodeURIComponent(username)}` + +const putCouch = async (path, username, body, opts) => { + const result = await fetch.json(`${couchEndpoint(username)}${path}`, { + ...opts, + method: 'PUT', + body, }) + result.username = username + return result } -const adduserCouch = (username, email, password, opts = {}) => { +const adduserCouch = async (username, email, password, opts = {}) => { const body = { - _id: 'org.couchdb.user:' + username, + _id: `org.couchdb.user:${username}`, name: username, password: password, email: email, @@ -136,129 +135,103 @@ const adduserCouch = (username, email, password, opts = {}) => { roles: [], date: new Date().toISOString(), } - const logObj = { + + log.verbose('adduser', 'before first PUT', { ...body, password: 'XXXXX', - } - log.verbose('adduser', 'before first PUT', logObj) - - const target = '/-/user/org.couchdb.user:' + encodeURIComponent(username) - return fetch.json(target, { - ...opts, - method: 'PUT', - body, - }).then(result => { - result.username = username - return result }) + + return putCouch('', username, body, opts) } -const loginCouch = (username, password, opts = {}) => { +const loginCouch = async (username, password, opts = {}) => { const body = { - _id: 'org.couchdb.user:' + username, + _id: `org.couchdb.user:${username}`, name: username, password: password, type: 'user', roles: [], date: new Date().toISOString(), } - const logObj = { + + log.verbose('login', 'before first PUT', { ...body, password: 'XXXXX', - } - log.verbose('login', 'before first PUT', logObj) + }) - const target = '/-/user/org.couchdb.user:' + encodeURIComponent(username) - return fetch.json(target, { - ...opts, - method: 'PUT', - body, - }).catch(err => { + try { + return await putCouch('', username, body, opts) + } catch (err) { if (err.code === 'E400') { err.message = `There is no user with the username "${username}".` throw err } + if (err.code !== 'E409') { throw err } - return fetch.json(target, { - ...opts, - query: { write: true }, - }).then(result => { - Object.keys(result).forEach(k => { - if (!body[k] || k === 'roles') { - body[k] = result[k] - } - }) - const { otp } = opts - return fetch.json(`${target}/-rev/${body._rev}`, { - ...opts, - method: 'PUT', - body, - forceAuth: { - username, - password: Buffer.from(password, 'utf8').toString('base64'), - otp, - }, - }) - }) - }).then(result => { - result.username = username - return result - }) -} + } -const get = (opts = {}) => fetch.json('/-/npm/v1/user', opts) + const result = await fetch.json(couchEndpoint(username), { + ...opts, + query: { write: true }, + }) -const set = (profile, opts = {}) => { - Object.keys(profile).forEach(key => { - // profile keys can't be empty strings, but they CAN be null - if (profile[key] === '') { - profile[key] = null + for (const k of Object.keys(result)) { + if (!body[k] || k === 'roles') { + body[k] = result[k] } - }) - return fetch.json('/-/npm/v1/user', { + } + + return putCouch(`/-rev/${body._rev}`, username, body, { ...opts, - method: 'POST', - body: profile, + forceAuth: { + username, + password: Buffer.from(password, 'utf8').toString('base64'), + otp: opts.otp, + }, }) } -const listTokens = (opts = {}) => { - const untilLastPage = (href, objects) => { - return fetch.json(href, opts).then(result => { - objects = objects ? objects.concat(result.objects) : result.objects - if (result.urls.next) { - return untilLastPage(result.urls.next, objects) - } else { - return objects - } - }) +const get = (opts = {}) => fetch.json('/-/npm/v1/user', opts) + +const set = (profile, opts = {}) => fetch.json('/-/npm/v1/user', { + ...opts, + method: 'POST', + // profile keys can't be empty strings, but they CAN be null + body: Object.fromEntries(Object.entries(profile).map(([k, v]) => [k, v === '' ? null : v])), +}) + +const paginate = async (href, opts, items = []) => { + const result = await fetch.json(href, opts) + items = items.concat(result.objects) + if (result.urls.next) { + return paginate(result.urls.next, opts, items) } - return untilLastPage('/-/npm/v1/tokens') + return items } -const removeToken = (tokenKey, opts = {}) => { - const target = `/-/npm/v1/tokens/token/${tokenKey}` - return fetch(target, { +const listTokens = (opts = {}) => paginate('/-/npm/v1/tokens', opts) + +const removeToken = async (tokenKey, opts = {}) => { + await fetch(`/-/npm/v1/tokens/token/${tokenKey}`, { ...opts, method: 'DELETE', ignoreBody: true, - }).then(() => null) -} - -const createToken = (password, readonly, cidrs, opts = {}) => { - return fetch.json('/-/npm/v1/tokens', { - ...opts, - method: 'POST', - body: { - password: password, - readonly: readonly, - cidr_whitelist: cidrs, - }, }) + return null } +const createToken = (password, readonly, cidrs, opts = {}) => fetch.json('/-/npm/v1/tokens', { + ...opts, + method: 'POST', + body: { + password: password, + readonly: readonly, + cidr_whitelist: cidrs, + }, +}) + class WebLoginInvalidResponse extends HttpErrorBase { constructor (method, res, body) { super(method, res, body) @@ -276,8 +249,6 @@ class WebLoginNotSupported extends HttpErrorBase { } } -const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) - module.exports = { adduserCouch, loginCouch, diff --git a/test/index.js b/test/index.js index 017c734..23637ba 100644 --- a/test/index.js +++ b/test/index.js @@ -1,8 +1,5 @@ -'use strict' - const test = require('tap').test const tnock = require('./fixtures/tnock.js') - const profile = require('..') const registry = 'https://registry.npmjs.org/' @@ -141,9 +138,9 @@ test('login fallback to couch when web login fails cancels opener promise', t => .reply(404, { error: 'Not found' }) let cancelled = false - const opener = (url, doneEmitter) => { + const opener = (url, { signal }) => { t.equal(url, loginUrl) - doneEmitter.on('abort', () => { + signal.addEventListener('abort', () => { cancelled = true }) }