From 5671679e4ff23018505b567442aef2267a4d58be Mon Sep 17 00:00:00 2001 From: Ricardo Devis Agullo Date: Mon, 10 Aug 2026 10:08:04 +0200 Subject: [PATCH] feat: add promise client APIs with callback deprecations Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- README.md | 8 +++ src/index.js | 115 ++++++++++++++++++++++++++++------- test/unit/client.js | 144 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 244 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 227a968..a3eb163 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,14 @@ Disclaimer: This project is still under heavy development and the API is likely # API +The client returns promises from `init`, `renderComponent`, +`renderComponents`, `getComponentsInfo`, and `renderTemplate` when the callback +is omitted. The promise resolves with the callback's first success value and +rejects with its error value: `renderComponent` and `renderTemplate` resolve to +HTML, while `renderComponents` resolves to the HTML array. Existing callback +forms keep their original return values and timing, and emit one +`DeprecationWarning` per process. + * [new Client()](#new-clientoptions) * [Client#init()](#clientinitoptions-callback) * [Client#getComponentsInfo()](#clientgetcomponentsinfocomponents-callback) diff --git a/src/index.js b/src/index.js index 1da6cd7..39c51e7 100644 --- a/src/index.js +++ b/src/index.js @@ -9,6 +9,29 @@ const validator = require('./validator'); const Warmup = require('./warmup'); const _ = require('./utils/helpers'); +const warningStoreKey = Symbol.for('opencomponents.deprecation-warnings'); +const callbackWarningId = 'node-oc-client-callback-api'; + +const warnAboutCallbacks = () => { + const warned = process[warningStoreKey] || new Set(); + process[warningStoreKey] = warned; + if (warned.has(callbackWarningId)) { + return; + } + + warned.add(callbackWarningId); + process.emitWarning( + 'The callback API of the Node.js oc-client is deprecated and will be removed in OpenComponents v1 - use the returned promises instead.', + 'DeprecationWarning' + ); +}; + +const toComponent = (componentName, options) => ({ + name: componentName, + version: options.version, + parameters: options.parameters || options.params +}); + module.exports = function(conf) { const config = sanitiser.sanitiseConfiguration(conf); const validationResult = validator.validateConfiguration(config); @@ -30,34 +53,51 @@ module.exports = function(conf) { } return { + supportsPromiseApi: true, init: function(options, callback) { - const _renderComponents = options.renderComponents || renderComponents; - const warmup = new Warmup(config, _renderComponents); - return warmup(options, callback); + if (_.isFunction(options)) { + callback = options; + options = {}; + } + options = options || {}; + + if (_.isFunction(callback)) { + warnAboutCallbacks(); + const _renderComponents = options.renderComponents || renderComponents; + return new Warmup(config, _renderComponents)(options, callback); + } + + return new Promise((resolve, reject) => { + const _renderComponents = options.renderComponents || renderComponents; + const warmup = new Warmup(config, _renderComponents); + warmup(options, (error, result) => + error ? reject(error) : resolve(result) + ); + }); }, renderComponent: function(componentName, options, callback) { if (_.isFunction(options)) { callback = options; options = {}; } + options = options || {}; + const components = [toComponent(componentName, options)]; - renderComponents( - [ - { - name: componentName, - version: options.version, - parameters: options.parameters || options.params - } - ], - options, - (errors, results, details) => { - if (errors) { - return callback(errors[0], results[0], details[0]); - } - - callback(null, results[0], details[0]); - } - ); + if (_.isFunction(callback)) { + warnAboutCallbacks(); + return renderComponents( + components, + options, + (errors, results, details) => + callback(errors ? errors[0] : null, results[0], details[0]) + ); + } + + return new Promise((resolve, reject) => { + renderComponents(components, options, (errors, results) => + errors ? reject(errors[0]) : resolve(results[0]) + ); + }); }, renderComponents: function(components, options, callback) { if (_.isFunction(options)) { @@ -65,11 +105,40 @@ module.exports = function(conf) { options = {}; } - renderComponents(components, options, callback); + if (_.isFunction(callback)) { + warnAboutCallbacks(); + return renderComponents(components, options, callback); + } + + return new Promise((resolve, reject) => { + renderComponents(components, options, (errors, results) => + errors ? reject(errors) : resolve(results) + ); + }); }, getComponentsInfo: function(components, callback) { - getComponentsInfo(components, callback); + if (_.isFunction(callback)) { + warnAboutCallbacks(); + return getComponentsInfo(components, callback); + } + + return new Promise((resolve, reject) => { + getComponentsInfo(components, (error, result) => + error ? reject(error) : resolve(result) + ); + }); }, - renderTemplate: renderTemplate + renderTemplate: function(template, model, options, callback) { + if (_.isFunction(callback)) { + warnAboutCallbacks(); + return renderTemplate(template, model, options, callback); + } + + return new Promise((resolve, reject) => { + renderTemplate(template, model, options, (error, html) => + error ? reject(error) : resolve(html) + ); + }); + } }; }; diff --git a/test/unit/client.js b/test/unit/client.js index cd49cd5..2293d1c 100644 --- a/test/unit/client.js +++ b/test/unit/client.js @@ -31,4 +31,148 @@ describe('client', () => { expect(init).to.throw('argh!'); }); }); + + describe('promise and callback APIs', () => { + const warningStoreKey = Symbol.for('opencomponents.deprecation-warnings'); + let client; + let renderComponents; + let getComponentsInfo; + let renderTemplate; + let warmup; + let emitWarning; + let previousWarningStore; + + const rejectionOf = async promise => { + try { + await promise; + } catch (error) { + return error; + } + throw new Error('Expected promise to reject'); + }; + + beforeEach(() => { + initialise(); + validatorStub.returns({ isValid: true }); + renderComponents = sinon.stub().yields(null, ['

hello

'], [{}]); + getComponentsInfo = sinon.stub().yields(null, [{ name: 'hello' }]); + renderTemplate = sinon.stub().yields(null, '

template

'); + warmup = sinon.stub().yields(null, { hello: '

hello

' }); + previousWarningStore = process[warningStoreKey]; + delete process[warningStoreKey]; + emitWarning = sinon.stub(process, 'emitWarning'); + client = injectr( + '../../src/index.js', + { + './validator': { validateConfiguration: validatorStub }, + './components-renderer': function() { + return renderComponents; + }, + './get-components-info': function() { + return getComponentsInfo; + }, + './template-renderer': function() { + return renderTemplate; + }, + './warmup': function() { + return warmup; + } + }, + { __dirname: '/something/', console: console, process } + )({}); + }); + + afterEach(() => { + emitWarning.restore(); + if (previousWarningStore) { + process[warningStoreKey] = previousWarningStore; + } else { + delete process[warningStoreKey]; + } + }); + + it('returns the first success value from every promise API', async () => { + expect(await client.init()).to.eql({ hello: '

hello

' }); + expect(await client.renderComponent('hello')).to.equal('

hello

'); + expect(await client.renderComponents([{ name: 'hello' }])).to.eql([ + '

hello

' + ]); + expect(await client.getComponentsInfo([{ name: 'hello' }])).to.eql([ + { name: 'hello' } + ]); + expect(await client.renderTemplate('template', {}, {})).to.equal( + '

template

' + ); + expect(emitWarning.called).to.be.false; + }); + + it('rejects with the original callback errors', async () => { + const error = new Error('failed'); + warmup.yields(error); + expect(await rejectionOf(client.init())).to.equal(error); + + renderComponents.yields([error], ['fallback'], [{}]); + expect(await rejectionOf(client.renderComponent('hello'))).to.equal( + error + ); + expect( + await rejectionOf(client.renderComponents([{ name: 'hello' }])) + ).to.eql([error]); + + getComponentsInfo.yields([error]); + expect( + await rejectionOf(client.getComponentsInfo([{ name: 'hello' }])) + ).to.eql([error]); + + renderTemplate.yields(error); + expect( + await rejectionOf(client.renderTemplate('template', {}, {})) + ).to.equal(error); + }); + + it('preserves synchronous exceptions as promise rejection reasons', async () => { + const error = new Error('synchronous failure'); + renderComponents.resetBehavior(); + renderComponents.throws(error); + + expect(await rejectionOf(client.renderComponent('hello'))).to.equal( + error + ); + }); + + it('preserves direct callback behavior and warns once', () => { + const callback = sinon.spy(); + + expect(client.init(callback)).to.be.undefined; + expect(client.renderComponent('hello', callback)).to.be.undefined; + expect(client.renderComponents([{ name: 'hello' }], callback)).to.be + .undefined; + expect(client.getComponentsInfo([{ name: 'hello' }], callback)).to.be + .undefined; + expect(client.renderTemplate('template', {}, {}, callback)).to.be + .undefined; + + expect(callback.callCount).to.equal(5); + expect(callback.secondCall.args).to.eql([null, '

hello

', {}]); + expect(emitWarning.calledOnce).to.be.true; + expect(emitWarning.firstCall.args[0]).to.contain('Node.js oc-client'); + expect(emitWarning.firstCall.args[1]).to.equal('DeprecationWarning'); + }); + + it('does not swallow exceptions thrown by legacy callbacks', () => { + expect(() => + client.renderComponent('hello', () => { + throw new Error('callback failed'); + }) + ).to.throw('callback failed'); + }); + + it('shares warning identity with the core deprecation utility', () => { + process[warningStoreKey] = new Set(['node-oc-client-callback-api']); + + client.renderComponent('hello', () => {}); + + expect(emitWarning.called).to.be.false; + }); + }); });