From 5525f81c802ebb563a8ddc966785c76167298fc2 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Thu, 23 Oct 2025 13:10:29 +0200 Subject: [PATCH 01/25] Add full API --- example.js | 51 - index.d.ts | 162 +++ index.js | 7 - lib/notification.js | 185 ---- lib/pushpad.js | 25 - package-lock.json | 1698 -------------------------------- package.json | 34 +- src/errors.js | 30 + src/httpClient.js | 209 ++++ src/index.js | 69 ++ src/resources/base.js | 41 + src/resources/notifications.js | 65 ++ src/resources/projects.js | 81 ++ src/resources/senders.js | 81 ++ src/resources/subscriptions.js | 121 +++ test/notification.js | 240 ----- test/pushpad.js | 20 - 17 files changed, 877 insertions(+), 2242 deletions(-) delete mode 100755 example.js create mode 100644 index.d.ts delete mode 100755 index.js delete mode 100755 lib/notification.js delete mode 100755 lib/pushpad.js delete mode 100644 package-lock.json mode change 100755 => 100644 package.json create mode 100644 src/errors.js create mode 100644 src/httpClient.js create mode 100644 src/index.js create mode 100644 src/resources/base.js create mode 100644 src/resources/notifications.js create mode 100644 src/resources/projects.js create mode 100644 src/resources/senders.js create mode 100644 src/resources/subscriptions.js delete mode 100755 test/notification.js delete mode 100755 test/pushpad.js diff --git a/example.js b/example.js deleted file mode 100755 index f5471b5..0000000 --- a/example.js +++ /dev/null @@ -1,51 +0,0 @@ -var pushpad = require('./index'); - -// process.env['NODE_TLS_REJECT_UNAUTHORIZED'] = '0'; - -var AUTH_TOKEN = '5374d7dfeffa2eb49965624ba7596a09'; -var PROJECT_ID = 123; - -var user1 = 'user1'; -var user2 = 'user2'; -var user3 = 'user3'; -var users = [user1, user2, user3]; -var tags = ['segment1', 'segment2']; - -var project = new pushpad.Pushpad({ - authToken: AUTH_TOKEN, - projectId: PROJECT_ID -}); - -console.log('HMAC signature for the uid: %s is: %s', user1, project.signatureFor(user1)); - -var notification = new pushpad.Notification({ - project: project, - body: 'Hello world!', - title: 'Website Name', - targetUrl: 'https://example.com' -}); - -notification.deliverTo(user1, function (err, result) { - console.log('Send notification to user:', user1); - console.log(err || result); -}); - -notification.deliverTo(users, function (err, result) { - console.log('Send notification to users:', users); - console.log(err || result); -}); - -notification.broadcast(function (err, result) { - console.log('Send broadcast notification'); - console.log(err || result); -}); - -notification.deliverTo(users, { tags: tags }, function (err, result) { - console.log('Send notification to users:', users, 'if they have at least one of the following tags:', tags); - console.log(err || result); -}); - -notification.broadcast({ tags: tags }, function (err, result) { - console.log('Send broadcast notification to segments:', tags); - console.log(err || result); -}); diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000..a1fe1ef --- /dev/null +++ b/index.d.ts @@ -0,0 +1,162 @@ +export interface Notification { + id?: number; + project_id?: number; + title?: string; + body?: string; + target_url?: string; + icon_url?: string; + badge_url?: string; + image_url?: string; + ttl?: number; + require_interaction?: boolean; + silent?: boolean; + custom_data?: Record; + send_at?: string; + click_url?: string; + custom_metrics?: unknown; + created_at?: string; + [key: string]: unknown; +} + +export interface NotificationCreateInput extends Omit { + body: string; +} + +export interface NotificationCreateResult { + id: number; + scheduled?: number; + uids?: string[]; + send_at?: string; + [key: string]: unknown; +} + +export interface NotificationListQuery { + page?: number; +} + +export interface Subscription { + id?: number; + project_id?: number; + endpoint?: string; + p256dh?: string; + auth?: string; + uid?: string; + tags?: string[]; + last_click_at?: string | null; + created_at?: string; + [key: string]: unknown; +} + +export interface SubscriptionCreateInput extends Omit { + endpoint: string; +} + +export interface SubscriptionUpdateInput extends Partial {} + +export interface SubscriptionListQuery { + page?: number; + per_page?: number; + perPage?: number; + uids?: string | string[]; + tags?: string | string[]; +} + +export interface Project { + id?: number; + sender_id?: number; + name?: string; + website?: string; + icon_url?: string; + badge_url?: string; + notifications_ttl?: number; + notifications_require_interaction?: boolean; + notifications_silent?: boolean; + created_at?: string; + [key: string]: unknown; +} + +export interface ProjectCreateInput extends Required>, + Omit {} + +export interface ProjectUpdateInput extends Partial {} + +export interface Sender { + id?: number; + name?: string; + vapid_private_key?: string; + vapid_public_key?: string; + created_at?: string; + [key: string]: unknown; +} + +export interface SenderCreateInput extends Required>, + Omit {} + +export interface SenderUpdateInput extends Partial {} + +export interface RequestOptions { + projectId?: number | string; +} + +export interface PushpadOptions { + authToken: string; + projectId?: number | string; + baseUrl?: string; + fetch?: typeof fetch; + timeout?: number; +} + +export class NotificationResource { + create(data: NotificationCreateInput, options?: RequestOptions): Promise; + findAll(query?: NotificationListQuery, options?: RequestOptions): Promise; + find(notificationId: number | string): Promise; + cancel(notificationId: number | string): Promise; +} + +export class SubscriptionResource { + create(data: SubscriptionCreateInput, options?: RequestOptions): Promise; + findAll(query?: SubscriptionListQuery, options?: RequestOptions): Promise; + find(subscriptionId: number | string, options?: RequestOptions): Promise; + update(subscriptionId: number | string, data: SubscriptionUpdateInput, options?: RequestOptions): Promise; + delete(subscriptionId: number | string, options?: RequestOptions): Promise; +} + +export class ProjectResource { + create(data: ProjectCreateInput): Promise; + findAll(): Promise; + find(projectId: number | string): Promise; + update(projectId: number | string, data: ProjectUpdateInput): Promise; + delete(projectId: number | string): Promise; +} + +export class SenderResource { + create(data: SenderCreateInput): Promise; + findAll(): Promise; + find(senderId: number | string): Promise; + update(senderId: number | string, data: SenderUpdateInput): Promise; + delete(senderId: number | string): Promise; +} + +export class Pushpad { + constructor(options: PushpadOptions); + notification: NotificationResource; + subscription: SubscriptionResource; + project: ProjectResource; + sender: SenderResource; + get projectId(): number | string | undefined; + setProjectId(projectId?: number | string): void; +} + +export class PushpadError extends Error { + status: number; + statusText: string; + body?: unknown; + headers?: Record; + request?: { + method: string; + url: string; + body?: unknown; + }; +} + +export default Pushpad; diff --git a/index.js b/index.js deleted file mode 100755 index e3873d6..0000000 --- a/index.js +++ /dev/null @@ -1,7 +0,0 @@ -var Pushpad = require('./lib/pushpad'); -var Notification = require('./lib/notification'); - -module.exports = { - Pushpad: Pushpad, - Notification: Notification -}; \ No newline at end of file diff --git a/lib/notification.js b/lib/notification.js deleted file mode 100755 index 421c709..0000000 --- a/lib/notification.js +++ /dev/null @@ -1,185 +0,0 @@ -var request = require('superagent'); -var http = require('http'); -var url = require('url'); - -module.exports = Notification; - -/** - * Create new Notification - * @param {Pushpad} options.project - * - * @param {string} options.body - A string representing the main text of the push notification. - * This field is mandatory. - * - * @param {string} [options.title] - A string representing the title of the push notification. - * This field is optional and the value defaults to the project name set in project settings. - * - * @param {string} [options.targetUrl] - The url the user is redirected to when clicks the push notification. - * This field is optional and the value defaults to the project website. - * - * @param {string} [options.iconUrl] - The url of a PNG or JPEG image that will be imported - * and used as the notification icon. This field is optional and the value defaults to the project icon. - * - * @param {Number} [options.ttl] - The number of seconds after which the notification should be dropped - * if the device of the user is offline. - * - * @constructor - */ -function Notification(options) { - this.project = options.project; - this.body = options.body; - this.title = options.title; - this.targetUrl = options.targetUrl; - this.iconUrl = options.iconUrl; - this.badgeUrl = options.badgeUrl; - this.imageUrl = options.imageUrl; - this.ttl = options.ttl; - this.requireInteraction = options.requireInteraction; - this.silent = options.silent; - this.urgent = options.urgent; - this.customData = options.customData; - this.customMetrics = options.customMetrics; - this.actions = options.actions; - this.starred = options.starred; - this.sendAt = options.sendAt; -} - -/** - * Send Notification to everyone - * @param {Object} options (optional) - * @param {Array} options.tags - * @param {function} callback - */ -Notification.prototype.broadcast = function () { - var options, callback; - if (arguments.length > 1) { - options = arguments[0]; - callback = arguments[1]; - } else { - options = {}; - callback = arguments[0]; - } - this._deliver(this._reqBody(null, options.tags), callback); -}; - -/** - * Send Notification to given users - * @param {string[]|string} uids - * @param {Object} options (optional) - * @param {Array} options.tags - * @param {function} callback - */ -Notification.prototype.deliverTo = function (uids) { - var options, callback; - if (arguments.length > 2) { - options = arguments[1]; - callback = arguments[2]; - } else { - options = {}; - callback = arguments[1]; - } - if (!uids) { - uids = []; // prevent broadcasting - } - this._deliver(this._reqBody(uids, options.tags), callback); -}; - - -Notification.prototype._reqHeaders = function () { - return { - 'Authorization': 'Token token="' + this.project.authToken + '"', - 'Content-Type': 'application/json;charset=UTF-8', - 'Accept': 'application/json' - }; -}; - -Notification.prototype._reqBody = function (uids, tags) { - var body = { - 'notification': { - 'body': this.body - } - }; - if (this.title) { - body.notification.title = this.title; - } - if (this.targetUrl) { - body.notification.target_url = this.targetUrl; - } - if (this.iconUrl) { - body.notification.icon_url = this.iconUrl; - } - if (this.badgeUrl) { - body.notification.badge_url = this.badgeUrl; - } - if (this.imageUrl) { - body.notification.image_url = this.imageUrl; - } - if (this.ttl != null) { - body.notification.ttl = this.ttl; - } - if (this.requireInteraction != null) { - body.notification.require_interaction = this.requireInteraction; - } - if (this.silent != null) { - body.notification.silent = this.silent; - } - if (this.urgent != null) { - body.notification.urgent = this.urgent; - } - if (this.customData) { - body.notification.custom_data = this.customData; - } - if (this.customMetrics) { - body.notification.custom_metrics = this.customMetrics; - } - if (this.actions && this.actions.length > 0) { - var actions = []; - for (var i = 0; i < this.actions.length; i++) { - actions[i] = { - title: this.actions[i].title - }; - if (this.actions[i].targetUrl) { - actions[i].target_url = this.actions[i].targetUrl; - } - if (this.actions[i].icon) { - actions[i].icon = this.actions[i].icon; - } - if (this.actions[i].action) { - actions[i].action = this.actions[i].action; - } - } - body.notification.actions = actions; - } - if (this.starred != null) { - body.notification.starred = this.starred; - } - if (this.sendAt) { - body.notification.send_at = this.sendAt.toJSON(); - } - - if (uids) { - body.uids = uids; - } - if (tags) { - body.tags = tags; - } - return body; -}; - -Notification.prototype._deliver = function (body, callback) { - request - .post('https://pushpad.xyz/api/v1/projects/' + this.project.projectId + '/notifications') - .set(this._reqHeaders()) - .send(body) - .end(function (err, res) { - if (err) { - callback(err); - } - else if (res.statusCode != 201) { - callback(new Error(res.body)); - } - else { - callback(null, res.body); - } - }); -}; diff --git a/lib/pushpad.js b/lib/pushpad.js deleted file mode 100755 index 8ef80af..0000000 --- a/lib/pushpad.js +++ /dev/null @@ -1,25 +0,0 @@ -var crypto = require('crypto'); - -module.exports = Pushpad; - -/** - * Create new Pushpad project instance - * @param {string} options.authToken - can be found in the user account settings - * @param {string|number} options.projectId - can be found in the project settings - * @constructor - */ -function Pushpad(options) { - this.authToken = options.authToken; - this.projectId = options.projectId; -} - -/** - * Create HMAC signature for given user id - * @param {string} uid - * @returns {string} - */ -Pushpad.prototype.signatureFor = function (uid) { - var hmac = crypto.createHmac('sha256', this.authToken); - hmac.update(uid); - return hmac.digest('hex'); -}; diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index d588d63..0000000 --- a/package-lock.json +++ /dev/null @@ -1,1698 +0,0 @@ -{ - "name": "pushpad", - "version": "1.0.2", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "pushpad", - "version": "1.0.2", - "license": "MIT", - "dependencies": { - "superagent": "^10.1.1" - }, - "devDependencies": { - "mocha": "^11.0.1", - "nock": "^13.5.6" - } - }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@noble/hashes": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", - "license": "MIT", - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@paralleldrive/cuid2": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz", - "integrity": "sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "^1.1.5" - } - }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/asap": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", - "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", - "license": "MIT" - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/browser-stdout": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", - "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", - "dev": true, - "license": "ISC" - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chalk/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/cliui/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/cliui/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/component-emitter": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", - "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cookiejar": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", - "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decamelize": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", - "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/dezalgo": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", - "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", - "license": "ISC", - "dependencies": { - "asap": "^2.0.0", - "wrappy": "1" - } - }, - "node_modules/diff": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", - "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/fast-safe-stringify": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", - "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", - "license": "MIT" - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", - "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", - "dev": true, - "license": "BSD-3-Clause", - "bin": { - "flat": "cli.js" - } - }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/formidable": { - "version": "3.5.4", - "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", - "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", - "license": "MIT", - "dependencies": { - "@paralleldrive/cuid2": "^2.2.2", - "dezalgo": "^1.0.4", - "once": "^1.4.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "url": "https://ko-fi.com/tunnckoCore/commissions" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/he": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", - "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", - "dev": true, - "license": "MIT", - "bin": { - "he": "bin/he" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-plain-obj": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", - "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "dev": true, - "license": "ISC" - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", - "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/mocha": { - "version": "11.7.1", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.1.tgz", - "integrity": "sha512-5EK+Cty6KheMS/YLPPMJC64g5V61gIR25KsRItHw6x4hEKT6Njp1n9LOlH4gpevuwMVS66SXaBBpg+RWZkza4A==", - "dev": true, - "license": "MIT", - "dependencies": { - "browser-stdout": "^1.3.1", - "chokidar": "^4.0.1", - "debug": "^4.3.5", - "diff": "^7.0.0", - "escape-string-regexp": "^4.0.0", - "find-up": "^5.0.0", - "glob": "^10.4.5", - "he": "^1.2.0", - "js-yaml": "^4.1.0", - "log-symbols": "^4.1.0", - "minimatch": "^9.0.5", - "ms": "^2.1.3", - "picocolors": "^1.1.1", - "serialize-javascript": "^6.0.2", - "strip-json-comments": "^3.1.1", - "supports-color": "^8.1.1", - "workerpool": "^9.2.0", - "yargs": "^17.7.2", - "yargs-parser": "^21.1.1", - "yargs-unparser": "^2.0.0" - }, - "bin": { - "_mocha": "bin/_mocha", - "mocha": "bin/mocha.js" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/nock": { - "version": "13.5.6", - "resolved": "https://registry.npmjs.org/nock/-/nock-13.5.6.tgz", - "integrity": "sha512-o2zOYiCpzRqSzPj0Zt/dQ/DqZeYoaQ7TUonc/xUPjCGl9WeHpNbxgVvOquXYAaJzI0M9BXV3HTzG0p8IUAbBTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.1.0", - "json-stringify-safe": "^5.0.1", - "propagate": "^2.0.0" - }, - "engines": { - "node": ">= 10.13" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/propagate": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", - "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, - "node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "randombytes": "^2.1.0" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/superagent": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.2.2.tgz", - "integrity": "sha512-vWMq11OwWCC84pQaFPzF/VO3BrjkCeewuvJgt1jfV0499Z1QSAWN4EqfMM5WlFDDX9/oP8JjlDKpblrmEoyu4Q==", - "license": "MIT", - "dependencies": { - "component-emitter": "^1.3.0", - "cookiejar": "^2.1.4", - "debug": "^4.3.4", - "fast-safe-stringify": "^2.1.1", - "form-data": "^4.0.0", - "formidable": "^3.5.4", - "methods": "^1.1.2", - "mime": "2.6.0", - "qs": "^6.11.0" - }, - "engines": { - "node": ">=14.18.0" - } - }, - "node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/workerpool": { - "version": "9.3.3", - "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-9.3.3.tgz", - "integrity": "sha512-slxCaKbYjEdFT/o2rH9xS1hf4uRDch1w7Uo+apxhZ+sf/1d9e0ZVkn42kPNGP2dgjIx6YFvSevj0zHvbWe2jdw==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-unparser": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", - "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", - "dev": true, - "license": "MIT", - "dependencies": { - "camelcase": "^6.0.0", - "decamelize": "^4.0.0", - "flat": "^5.0.2", - "is-plain-obj": "^2.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/yargs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/yargs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - } - } -} diff --git a/package.json b/package.json old mode 100755 new mode 100644 index f32ffe1..605a4ff --- a/package.json +++ b/package.json @@ -1,22 +1,24 @@ { "name": "pushpad", - "version": "1.0.2", - "description": "Web push notifications for Chrome, Edge, Firefox and Safari using Pushpad.", - "author": "Pushpad ", - "homepage": "https://pushpad.xyz", - "repository": { - "type": "git", - "url": "https://github.com/pushpad/pushpad-node.git" + "version": "2.0.0", + "description": "Official Node.js client for the Pushpad API.", + "type": "module", + "main": "src/index.js", + "exports": { + ".": { + "import": "./src/index.js" + } }, + "types": "index.d.ts", + "keywords": [ + "pushpad", + "web-push", + "notifications", + "api-client" + ], "license": "MIT", - "dependencies": { - "superagent": "^10.1.1" + "engines": { + "node": ">=20" }, - "devDependencies": { - "mocha": "^11.0.1", - "nock": "^13.5.6" - }, - "scripts": { - "test": "mocha" - } + "dependencies": {} } diff --git a/src/errors.js b/src/errors.js new file mode 100644 index 0000000..81f6889 --- /dev/null +++ b/src/errors.js @@ -0,0 +1,30 @@ +/** + * Custom error raised when the Pushpad API returns an unsuccessful status code. + */ +export class PushpadError extends Error { + /** + * @param {string} message + * @param {{ + * status: number, + * statusText?: string, + * body?: unknown, + * headers?: Record, + * request?: { + * method: string, + * url: string, + * body?: unknown + * } + * }} [meta] + */ + constructor(message, meta = {}) { + super(message); + this.name = 'PushpadError'; + this.status = meta.status ?? 0; + this.statusText = meta.statusText ?? ''; + this.body = meta.body; + this.headers = meta.headers ?? {}; + this.request = meta.request; + } +} + +export default PushpadError; diff --git a/src/httpClient.js b/src/httpClient.js new file mode 100644 index 0000000..2057235 --- /dev/null +++ b/src/httpClient.js @@ -0,0 +1,209 @@ +import { PushpadError } from './errors.js'; + +const DEFAULT_BASE_URL = 'https://pushpad.xyz/api/v1'; + +/** + * @param {typeof fetch | undefined} customFetch + * @returns {typeof fetch} + */ +function resolveFetch(customFetch) { + if (typeof customFetch === 'function') { + return customFetch; + } + + if (typeof globalThis.fetch === 'function') { + return globalThis.fetch.bind(globalThis); + } + + throw new Error('A fetch implementation is required. Provide one through the Pushpad constructor options.'); +} + +/** + * Converts a query object into URLSearchParams, supporting array values. + * @param {Record | undefined} query + * @returns {URLSearchParams | undefined} + */ +function buildQueryParams(query) { + if (!query || Object.keys(query).length === 0) { + return undefined; + } + + const params = new URLSearchParams(); + + for (const [key, value] of Object.entries(query)) { + if (value === undefined || value === null) { + continue; + } + + if (Array.isArray(value)) { + for (const entry of value) { + if (entry === undefined || entry === null) continue; + params.append(key, String(entry)); + } + continue; + } + + params.append(key, String(value)); + } + + return params; +} + +/** + * @typedef {object} HttpClientRequestOptions + * @property {Record} [query] + * @property {unknown} [body] + * @property {Record} [headers] + * @property {number | number[]} [expectedStatuses] + * @property {boolean} [expectBody] + */ + +/** + * Lightweight HTTP client tailored to the Pushpad API. + */ +export class HttpClient { + /** + * @param {{ + * authToken: string, + * baseUrl?: string, + * fetch?: typeof fetch, + * timeout?: number + * }} options + */ + constructor(options) { + if (!options || typeof options !== 'object') { + throw new Error('HttpClient requires an options object.'); + } + + const { + authToken, + baseUrl = DEFAULT_BASE_URL, + fetch: customFetch, + timeout + } = options; + + if (!authToken) { + throw new Error('An authToken must be provided.'); + } + + this.authToken = authToken; + this.baseUrl = baseUrl.replace(/\/$/, ''); + this.fetch = resolveFetch(customFetch); + this.timeout = timeout; + } + + /** + * Performs an HTTP request against the Pushpad API. + * @param {string} method + * @param {string} path + * @param {HttpClientRequestOptions} [options] + */ + async request(method, path, options = {}) { + const { + query, + body, + headers = {}, + expectedStatuses, + expectBody + } = options; + + const base = this.baseUrl.endsWith('/') ? this.baseUrl : `${this.baseUrl}/`; + const normalizedPath = path.startsWith('/') ? path.slice(1) : path; + const url = new URL(normalizedPath, base); + const params = buildQueryParams(query); + if (params) { + url.search = params.toString(); + } + + const requestHeaders = new Headers({ + Accept: 'application/json', + Authorization: `Bearer ${this.authToken}` + }); + + if (body !== undefined) { + requestHeaders.set('Content-Type', 'application/json'); + } + + for (const [key, value] of Object.entries(headers)) { + requestHeaders.set(key, value); + } + + const controller = typeof AbortController === 'function' ? new AbortController() : undefined; + let timeoutId; + + const fetchPromise = this.fetch(url.toString(), { + method, + headers: requestHeaders, + body: body !== undefined ? JSON.stringify(body) : undefined, + signal: controller?.signal + }); + + if (controller && this.timeout && this.timeout > 0) { + timeoutId = setTimeout(() => controller.abort(), this.timeout); + } + + let response; + try { + response = await fetchPromise; + } catch (error) { + if (error?.name === 'AbortError') { + throw new PushpadError('Request timed out', { + status: 0, + request: { method, url: url.toString(), body } + }); + } + throw error; + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + } + + const expected = Array.isArray(expectedStatuses) + ? expectedStatuses + : expectedStatuses !== undefined + ? [expectedStatuses] + : undefined; + + const successfulStatusCodes = expected ?? [200, 201, 202, 204]; + const hasSuccessfulStatus = successfulStatusCodes.includes(response.status); + + const contentType = response.headers.get('content-type') ?? ''; + const shouldParseBody = expectBody ?? (response.status !== 204 && contentType.includes('application/json')); + + let parsedBody; + if (shouldParseBody) { + const raw = await response.text(); + if (raw) { + if (contentType.includes('application/json')) { + try { + parsedBody = JSON.parse(raw); + } catch (error) { + throw new PushpadError('Failed to parse JSON response', { + status: response.status, + statusText: response.statusText, + body: raw, + request: { method, url: url.toString(), body } + }); + } + } else { + parsedBody = raw; + } + } + } + + if (!hasSuccessfulStatus) { + throw new PushpadError(`Pushpad API request failed with status ${response.status}`, { + status: response.status, + statusText: response.statusText, + body: parsedBody, + headers: Object.fromEntries(response.headers.entries()), + request: { method, url: url.toString(), body } + }); + } + + return parsedBody; + } +} + +export default HttpClient; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..aba7e25 --- /dev/null +++ b/src/index.js @@ -0,0 +1,69 @@ +import HttpClient from './httpClient.js'; +import { PushpadError } from './errors.js'; +import { NotificationResource } from './resources/notifications.js'; +import { SubscriptionResource } from './resources/subscriptions.js'; +import { ProjectResource } from './resources/projects.js'; +import { SenderResource } from './resources/senders.js'; + +/** + * @typedef {object} PushpadOptions + * @property {string} authToken + * @property {number | string} [projectId] + * @property {string} [baseUrl] + * @property {typeof fetch} [fetch] + * @property {number} [timeout] + */ + +/** + * Entry point for interacting with the Pushpad API. + */ +export class Pushpad { + /** + * @param {PushpadOptions} options + */ + constructor(options) { + if (!options || typeof options !== 'object') { + throw new Error('Pushpad requires an options object.'); + } + + const { authToken, projectId, baseUrl, fetch, timeout } = options; + + if (!authToken) { + throw new Error('authToken is required to initialise Pushpad.'); + } + + this._defaultProjectId = projectId ?? undefined; + + this.client = new HttpClient({ authToken, baseUrl, fetch, timeout }); + + const getProjectId = (opts) => { + if (opts && Object.prototype.hasOwnProperty.call(opts, 'projectId')) { + return opts.projectId; + } + return this._defaultProjectId; + }; + + this.notification = new NotificationResource(this.client, getProjectId); + this.subscription = new SubscriptionResource(this.client, getProjectId); + this.project = new ProjectResource(this.client, getProjectId); + this.sender = new SenderResource(this.client, getProjectId); + } + + /** + * Overrides the default project id used for project-scoped endpoints. + * @param {number | string | undefined} projectId + */ + setProjectId(projectId) { + this._defaultProjectId = projectId ?? undefined; + } + + /** + * @returns {number | string | undefined} + */ + get projectId() { + return this._defaultProjectId; + } +} + +export { PushpadError }; +export default Pushpad; diff --git a/src/resources/base.js b/src/resources/base.js new file mode 100644 index 0000000..a244ba3 --- /dev/null +++ b/src/resources/base.js @@ -0,0 +1,41 @@ +/** + * Base class providing helpers shared across Pushpad API resources. + */ +export class ResourceBase { + /** + * @param {import('../httpClient.js').HttpClient} client + * @param {(options?: { projectId?: number | string }) => (number | string | undefined)} getProjectId + */ + constructor(client, getProjectId) { + this.client = client; + this.getProjectId = getProjectId; + } + + /** + * Resolves the project id to be used for a request. + * @param {{ projectId?: number | string }} [options] + * @returns {string} + */ + requireProjectId(options) { + const projectId = this.getProjectId?.(options); + if (projectId === undefined || projectId === null || projectId === '') { + throw new Error('A projectId is required. Pass it in the Pushpad constructor or the call options.'); + } + return String(projectId); + } + + /** + * Ensures IDs used in paths are valid strings. + * @param {number | string} id + * @param {string} descriptor + * @returns {string} + */ + ensureId(id, descriptor) { + if (id === undefined || id === null || id === '') { + throw new Error(`${descriptor} is required`); + } + return encodeURIComponent(String(id)); + } +} + +export default ResourceBase; diff --git a/src/resources/notifications.js b/src/resources/notifications.js new file mode 100644 index 0000000..84ef435 --- /dev/null +++ b/src/resources/notifications.js @@ -0,0 +1,65 @@ +import { ResourceBase } from './base.js'; + +/** + * Provides access to Pushpad notification endpoints. + */ +export class NotificationResource extends ResourceBase { + /** + * Creates and sends a new notification. + * @param {Record} data + * @param {{ projectId?: number | string }} [options] + * @returns {Promise>} + */ + async create(data, options) { + if (!data || typeof data !== 'object') { + throw new Error('Notification data must be a non-empty object.'); + } + + const projectId = this.requireProjectId(options); + return this.client.request('POST', `projects/${projectId}/notifications`, { + body: data, + expectedStatuses: 201 + }); + } + + /** + * Lists notifications for a project. + * @param {{ page?: number }} [query] + * @param {{ projectId?: number | string }} [options] + * @returns {Promise} + */ + async findAll(query, options) { + const projectId = this.requireProjectId(options); + return this.client.request('GET', `projects/${projectId}/notifications`, { + query, + expectedStatuses: 200 + }); + } + + /** + * Retrieves a single notification. + * @param {number | string} notificationId + * @returns {Promise>} + */ + async find(notificationId) { + const id = this.ensureId(notificationId, 'notificationId'); + return this.client.request('GET', `notifications/${id}`, { + expectedStatuses: 200 + }); + } + + /** + * Cancels a scheduled notification. + * @param {number | string} notificationId + * @returns {Promise} + */ + async cancel(notificationId) { + const id = this.ensureId(notificationId, 'notificationId'); + await this.client.request('DELETE', `notifications/${id}/cancel`, { + expectedStatuses: 204, + expectBody: false + }); + } +} + +export default NotificationResource; diff --git a/src/resources/projects.js b/src/resources/projects.js new file mode 100644 index 0000000..d2c186a --- /dev/null +++ b/src/resources/projects.js @@ -0,0 +1,81 @@ +import { ResourceBase } from './base.js'; + +/** + * Provides access to Pushpad project endpoints. + */ +export class ProjectResource extends ResourceBase { + constructor(client, getProjectId) { + super(client, getProjectId); + } + + /** + * Creates a new project. + * @param {Record} data + * @returns {Promise>} + */ + async create(data) { + if (!data || typeof data !== 'object') { + throw new Error('Project data must be a non-empty object.'); + } + + return this.client.request('POST', 'projects', { + body: data, + expectedStatuses: 201 + }); + } + + /** + * Lists all projects. + * @returns {Promise} + */ + async findAll() { + return this.client.request('GET', 'projects', { + expectedStatuses: 200 + }); + } + + /** + * Retrieves a project by id. + * @param {number | string} projectId + * @returns {Promise>} + */ + async find(projectId) { + const id = this.ensureId(projectId, 'projectId'); + return this.client.request('GET', `projects/${id}`, { + expectedStatuses: 200 + }); + } + + /** + * Updates a project. + * @param {number | string} projectId + * @param {Record} data + * @returns {Promise>} + */ + async update(projectId, data) { + if (!data || typeof data !== 'object') { + throw new Error('Project data must be a non-empty object.'); + } + + const id = this.ensureId(projectId, 'projectId'); + return this.client.request('PATCH', `projects/${id}`, { + body: data, + expectedStatuses: 200 + }); + } + + /** + * Deletes a project. + * @param {number | string} projectId + * @returns {Promise} + */ + async delete(projectId) { + const id = this.ensureId(projectId, 'projectId'); + await this.client.request('DELETE', `projects/${id}`, { + expectedStatuses: 202, + expectBody: false + }); + } +} + +export default ProjectResource; diff --git a/src/resources/senders.js b/src/resources/senders.js new file mode 100644 index 0000000..e270071 --- /dev/null +++ b/src/resources/senders.js @@ -0,0 +1,81 @@ +import { ResourceBase } from './base.js'; + +/** + * Provides access to Pushpad sender endpoints. + */ +export class SenderResource extends ResourceBase { + constructor(client, getProjectId) { + super(client, getProjectId); + } + + /** + * Creates a new sender. + * @param {Record} data + * @returns {Promise>} + */ + async create(data) { + if (!data || typeof data !== 'object') { + throw new Error('Sender data must be a non-empty object.'); + } + + return this.client.request('POST', 'senders', { + body: data, + expectedStatuses: 201 + }); + } + + /** + * Lists all senders. + * @returns {Promise} + */ + async findAll() { + return this.client.request('GET', 'senders', { + expectedStatuses: 200 + }); + } + + /** + * Retrieves a sender by id. + * @param {number | string} senderId + * @returns {Promise>} + */ + async find(senderId) { + const id = this.ensureId(senderId, 'senderId'); + return this.client.request('GET', `senders/${id}`, { + expectedStatuses: 200 + }); + } + + /** + * Updates a sender. + * @param {number | string} senderId + * @param {Record} data + * @returns {Promise>} + */ + async update(senderId, data) { + if (!data || typeof data !== 'object') { + throw new Error('Sender data must be a non-empty object.'); + } + + const id = this.ensureId(senderId, 'senderId'); + return this.client.request('PATCH', `senders/${id}`, { + body: data, + expectedStatuses: 200 + }); + } + + /** + * Deletes a sender. + * @param {number | string} senderId + * @returns {Promise} + */ + async delete(senderId) { + const id = this.ensureId(senderId, 'senderId'); + await this.client.request('DELETE', `senders/${id}`, { + expectedStatuses: 204, + expectBody: false + }); + } +} + +export default SenderResource; diff --git a/src/resources/subscriptions.js b/src/resources/subscriptions.js new file mode 100644 index 0000000..7471a4a --- /dev/null +++ b/src/resources/subscriptions.js @@ -0,0 +1,121 @@ +import { ResourceBase } from './base.js'; + +function normalizeQuery(query) { + if (!query) return undefined; + + const normalized = {}; + + if (query.page !== undefined) { + normalized.page = query.page; + } + + if (query.per_page !== undefined) { + normalized.per_page = query.per_page; + } else if (query.perPage !== undefined) { + normalized.per_page = query.perPage; + } + + if (query.uids !== undefined) { + normalized['uids[]'] = Array.isArray(query.uids) ? query.uids : [query.uids]; + } + + if (query.tags !== undefined) { + normalized['tags[]'] = Array.isArray(query.tags) ? query.tags : [query.tags]; + } + + return normalized; +} + +/** + * Provides access to Pushpad subscription endpoints. + */ +export class SubscriptionResource extends ResourceBase { + /** + * Creates a new subscription. + * @param {Record} data + * @param {{ projectId?: number | string }} [options] + * @returns {Promise>} + */ + async create(data, options) { + if (!data || typeof data !== 'object') { + throw new Error('Subscription data must be a non-empty object.'); + } + + const projectId = this.requireProjectId(options); + return this.client.request('POST', `projects/${projectId}/subscriptions`, { + body: data, + expectedStatuses: 201 + }); + } + + /** + * Lists subscriptions for a project. + * @param {{ + * page?: number, + * per_page?: number, + * perPage?: number, + * uids?: string | string[], + * tags?: string | string[] + * }} [query] + * @param {{ projectId?: number | string }} [options] + * @returns {Promise} + */ + async findAll(query, options) { + const projectId = this.requireProjectId(options); + return this.client.request('GET', `projects/${projectId}/subscriptions`, { + query: normalizeQuery(query), + expectedStatuses: 200 + }); + } + + /** + * Retrieves a single subscription. + * @param {number | string} subscriptionId + * @param {{ projectId?: number | string }} [options] + * @returns {Promise>} + */ + async find(subscriptionId, options) { + const projectId = this.requireProjectId(options); + const id = this.ensureId(subscriptionId, 'subscriptionId'); + return this.client.request('GET', `projects/${projectId}/subscriptions/${id}`, { + expectedStatuses: 200 + }); + } + + /** + * Updates an existing subscription. + * @param {number | string} subscriptionId + * @param {Record} data + * @param {{ projectId?: number | string }} [options] + * @returns {Promise>} + */ + async update(subscriptionId, data, options) { + if (!data || typeof data !== 'object') { + throw new Error('Subscription data must be a non-empty object.'); + } + + const projectId = this.requireProjectId(options); + const id = this.ensureId(subscriptionId, 'subscriptionId'); + return this.client.request('PATCH', `projects/${projectId}/subscriptions/${id}`, { + body: data, + expectedStatuses: 200 + }); + } + + /** + * Deletes a subscription. + * @param {number | string} subscriptionId + * @param {{ projectId?: number | string }} [options] + * @returns {Promise} + */ + async delete(subscriptionId, options) { + const projectId = this.requireProjectId(options); + const id = this.ensureId(subscriptionId, 'subscriptionId'); + await this.client.request('DELETE', `projects/${projectId}/subscriptions/${id}`, { + expectedStatuses: 204, + expectBody: false + }); + } +} + +export default SubscriptionResource; diff --git a/test/notification.js b/test/notification.js deleted file mode 100755 index 7cf547a..0000000 --- a/test/notification.js +++ /dev/null @@ -1,240 +0,0 @@ -var assert = require('assert'); -var nock = require('nock'); - -var Pushpad = require('../lib/pushpad'); -var Notification = require('../lib/notification'); - -var AUTH_TOKEN = '5374d7dfeffa2eb49965624ba7596a09'; -var PROJECT_ID = 123; - -var project = new Pushpad({authToken: AUTH_TOKEN, projectId: PROJECT_ID}); - -var notification = new Notification({ - project: project, - body: 'Hello world!', - title: 'Website Name', - targetUrl: 'https://example.com', - iconUrl: 'https://example.com/assets/icon.png', - badgeUrl: 'https://example.com/assets/badge.png', - imageUrl: 'https://example.com/assets/image.png', - ttl: 600, - requireInteraction: true, - silent: true, - urgent: true, - customData: '123', - customMetrics: ['examples', 'another_metric'], - actions: [ - { - title: 'My Button 1', - targetUrl: 'https://example.com/button-link', - icon: 'https://example.com/assets/button-icon.png', - action: 'myActionName' - } - ], - starred: true, - sendAt: new Date(Date.UTC(2016, 7 - 1, 25, 10, 9)) -}); - - -describe('Notification', function () { - - describe('#broadcast()', function () { - it('should send correct request to broadcast notification', function (done) { - notification.broadcast(function (err, result) { - if (err) return done(err); - assert.deepEqual(result, {scheduled: 0}); - done(); - }); - }); - - before(function () { - nock('https://pushpad.xyz', { - reqheaders: { - 'authorization': 'Token token="5374d7dfeffa2eb49965624ba7596a09"', - 'content-type': 'application/json;charset=UTF-8', - 'accept': 'application/json' - } - }) - .post('/api/v1/projects/123/notifications', { - 'notification': { - 'body': 'Hello world!', - 'title': 'Website Name', - 'target_url': 'https://example.com', - 'icon_url': 'https://example.com/assets/icon.png', - 'badge_url': 'https://example.com/assets/badge.png', - 'image_url': 'https://example.com/assets/image.png', - 'ttl': 600, - 'require_interaction': true, - 'silent': true, - 'urgent': true, - 'custom_data': '123', - 'custom_metrics': ['examples', 'another_metric'], - 'actions': [ - { - 'title': 'My Button 1', - 'target_url': 'https://example.com/button-link', - 'icon': 'https://example.com/assets/button-icon.png', - 'action': 'myActionName' - } - ], - 'starred': true, - 'send_at': '2016-07-25T10:09:00.000Z' - } - }) - .reply(201, {scheduled: 0}); - }); - }); - - after(function () { - nock.restore(); - }); - - describe('#deliverTo()', function () { - it('should send correct request to deliver notification to single user', function (done) { - notification.deliverTo('user1', function (err, result) { - if (err) return done(err); - assert.deepEqual(result, {scheduled: 0}); - done(); - }); - }); - - before(function () { - nock('https://pushpad.xyz/', { - reqheaders: { - 'Authorization': 'Token token="5374d7dfeffa2eb49965624ba7596a09"', - 'Content-Type': 'application/json;charset=UTF-8', - 'Accept': 'application/json' - } - }) - .post('/api/v1/projects/123/notifications', { - 'notification': { - 'body': 'Hello world!', - 'title': 'Website Name', - 'target_url': 'https://example.com', - 'icon_url': 'https://example.com/assets/icon.png', - 'badge_url': 'https://example.com/assets/badge.png', - 'image_url': 'https://example.com/assets/image.png', - 'ttl': 600, - 'require_interaction': true, - 'silent': true, - 'urgent': true, - 'custom_data': '123', - 'custom_metrics': ['examples', 'another_metric'], - 'actions': [ - { - 'title': 'My Button 1', - 'target_url': 'https://example.com/button-link', - 'icon': 'https://example.com/assets/button-icon.png', - 'action': 'myActionName' - } - ], - 'starred': true, - 'send_at': '2016-07-25T10:09:00.000Z' - }, - 'uids': 'user1' - }) - .reply(201, {scheduled: 0}); - }); - }); - - after(function () { - nock.restore(); - }); - - describe('#deliverTo()', function () { - it('should send correct request to deliver notification to multiple users', function (done) { - notification.deliverTo(['user1', 'user2', 'user3'], function (err, result) { - if (err) return done(err); - assert.deepEqual(result, {scheduled: 0}); - done(); - }); - }); - - before(function () { - nock('https://pushpad.xyz/', { - reqheaders: { - 'Authorization': 'Token token="5374d7dfeffa2eb49965624ba7596a09"', - 'Content-Type': 'application/json;charset=UTF-8', - 'Accept': 'application/json' - } - }) - .post('/api/v1/projects/123/notifications', { - 'notification': { - 'body': 'Hello world!', - 'title': 'Website Name', - 'target_url': 'https://example.com', - 'icon_url': 'https://example.com/assets/icon.png', - 'badge_url': 'https://example.com/assets/badge.png', - 'image_url': 'https://example.com/assets/image.png', - 'ttl': 600, - 'require_interaction': true, - 'silent': true, - 'urgent': true, - 'custom_data': '123', - 'custom_metrics': ['examples', 'another_metric'], - 'actions': [ - { - 'title': 'My Button 1', - 'target_url': 'https://example.com/button-link', - 'icon': 'https://example.com/assets/button-icon.png', - 'action': 'myActionName' - } - ], - 'starred': true, - 'send_at': '2016-07-25T10:09:00.000Z' - }, - 'uids': ['user1', 'user2', 'user3'] - }) - .reply(201, {scheduled: 0}); - }); - }); - - after(function () { - nock.restore(); - }); - - describe('#deliverTo()', function () { - it('should never broadcast a notification', function (done) { - notification.deliverTo(null, function (err, result) { - if (err) return done(err); - done(); - }); - }); - - before(function () { - nock('https://pushpad.xyz/') - .post('/api/v1/projects/123/notifications', { - 'notification': { - 'body': 'Hello world!', - 'title': 'Website Name', - 'target_url': 'https://example.com', - 'icon_url': 'https://example.com/assets/icon.png', - 'badge_url': 'https://example.com/assets/badge.png', - 'image_url': 'https://example.com/assets/image.png', - 'ttl': 600, - 'require_interaction': true, - 'silent': true, - 'urgent': true, - 'custom_data': '123', - 'custom_metrics': ['examples', 'another_metric'], - 'actions': [ - { - 'title': 'My Button 1', - 'target_url': 'https://example.com/button-link', - 'icon': 'https://example.com/assets/button-icon.png', - 'action': 'myActionName' - } - ], - 'starred': true, - 'send_at': '2016-07-25T10:09:00.000Z' - }, - 'uids': [] - }) - .reply(201, {scheduled: 0}); - }); - }); - - after(function () { - nock.restore(); - }); -}); diff --git a/test/pushpad.js b/test/pushpad.js deleted file mode 100755 index b5f82ed..0000000 --- a/test/pushpad.js +++ /dev/null @@ -1,20 +0,0 @@ -var assert = require('assert'); - -var Pushpad = require('../lib/pushpad'); - -var AUTH_TOKEN = '5374d7dfeffa2eb49965624ba7596a09'; -var PROJECT_ID = 123; - -var project = new Pushpad({authToken: AUTH_TOKEN, projectId: PROJECT_ID}); - -describe('Pushpad', function () { - - describe('#signatureFor()', function () { - it('should return correct signature', function () { - var actual = project.signatureFor('user12345'); - var expected = '6627820dab00a1971f2a6d3ff16a5ad8ba4048a02b2d402820afc61aefd0b69f'; - assert.equal(actual, expected); - }); - }); - -}); From 9c94946e58790d0632d68753d4421d5dcbf10515 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Thu, 23 Oct 2025 15:11:34 +0200 Subject: [PATCH 02/25] Add tests --- package.json | 3 ++ test/helpers/fetchStub.js | 43 ++++++++++++++++ test/helpers/inspectFetch.js | 10 ++++ test/httpClient.test.js | 93 ++++++++++++++++++++++++++++++++++ test/notifications.test.js | 46 +++++++++++++++++ test/projects.test.js | 86 +++++++++++++++++++++++++++++++ test/senders.test.js | 79 +++++++++++++++++++++++++++++ test/subscriptions.test.js | 98 ++++++++++++++++++++++++++++++++++++ 8 files changed, 458 insertions(+) create mode 100644 test/helpers/fetchStub.js create mode 100644 test/helpers/inspectFetch.js create mode 100644 test/httpClient.test.js create mode 100644 test/notifications.test.js create mode 100644 test/projects.test.js create mode 100644 test/senders.test.js create mode 100644 test/subscriptions.test.js diff --git a/package.json b/package.json index 605a4ff..b6d0c81 100644 --- a/package.json +++ b/package.json @@ -20,5 +20,8 @@ "engines": { "node": ">=20" }, + "scripts": { + "test": "node --test" + }, "dependencies": {} } diff --git a/test/helpers/fetchStub.js b/test/helpers/fetchStub.js new file mode 100644 index 0000000..39c394c --- /dev/null +++ b/test/helpers/fetchStub.js @@ -0,0 +1,43 @@ +export function createFetchStub(responses = []) { + const calls = []; + + const stub = async (url, options = {}) => { + calls.push({ url, options }); + + const next = responses.length ? responses.shift() : {}; + const status = next.status ?? 200; + const statusText = next.statusText ?? (status >= 400 ? 'Error' : 'OK'); + const headersInit = next.headers ?? {}; + const headers = new Headers(headersInit); + + if (!headers.has('content-type') && status !== 204) { + headers.set('content-type', 'application/json'); + } + + const body = next.body; + + return { + status, + statusText, + headers, + text: async () => { + if (status === 204) { + return ''; + } + if (body === undefined) { + return ''; + } + return typeof body === 'string' ? body : JSON.stringify(body); + } + }; + }; + + Object.defineProperty(stub, 'calls', { + value: calls, + enumerable: true + }); + + return stub; +} + +export default createFetchStub; diff --git a/test/helpers/inspectFetch.js b/test/helpers/inspectFetch.js new file mode 100644 index 0000000..ba582b5 --- /dev/null +++ b/test/helpers/inspectFetch.js @@ -0,0 +1,10 @@ +import assert from 'node:assert/strict'; + +export function parseLastCall(fetchStub) { + const call = fetchStub.calls.at(-1); + assert(call, 'Expected fetch to be called'); + const url = new URL(call.url); + return { call, url }; +} + +export default parseLastCall; diff --git a/test/httpClient.test.js b/test/httpClient.test.js new file mode 100644 index 0000000..b363b2f --- /dev/null +++ b/test/httpClient.test.js @@ -0,0 +1,93 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { HttpClient } from '../src/httpClient.js'; +import { PushpadError } from '../src/errors.js'; +import { createFetchStub } from './helpers/fetchStub.js'; + +test('HttpClient sends JSON payloads with auth header', async () => { + const fetchStub = createFetchStub([{ status: 200, body: { ok: true } }]); + const client = new HttpClient({ authToken: 'token', fetch: fetchStub }); + + const response = await client.request('POST', 'foo', { body: { bar: 1 } }); + + assert.deepEqual(response, { ok: true }); + assert.equal(fetchStub.calls.length, 1); + const [call] = fetchStub.calls; + assert.equal(call.url, 'https://pushpad.xyz/api/v1/foo'); + assert.equal(call.options.method, 'POST'); + assert.equal(call.options.headers.get('Authorization'), 'Bearer token'); + assert.equal(call.options.headers.get('Content-Type'), 'application/json'); + assert.equal(call.options.body, JSON.stringify({ bar: 1 })); +}); + +test('HttpClient serialises query parameters including arrays', async () => { + const fetchStub = createFetchStub([{ status: 200, body: {} }]); + const client = new HttpClient({ authToken: 'token', fetch: fetchStub }); + + await client.request('GET', 'resources', { + query: { tags: ['a', 'b'], single: 'value', truthy: true, maybe: undefined } + }); + + const call = fetchStub.calls.at(-1); + const url = new URL(call.url); + assert.equal(url.pathname, '/api/v1/resources'); + assert.deepEqual(url.searchParams.getAll('tags'), ['a', 'b']); + assert.equal(url.searchParams.get('single'), 'value'); + assert.equal(url.searchParams.get('truthy'), 'true'); + assert.equal(url.searchParams.has('maybe'), false); +}); + +test('HttpClient throws PushpadError for unsuccessful statuses', async () => { + const fetchStub = createFetchStub([ + { status: 400, body: { error: 'invalid' } } + ]); + const client = new HttpClient({ authToken: 'token', fetch: fetchStub }); + + await assert.rejects( + client.request('GET', 'resources'), + (error) => { + assert(error instanceof PushpadError); + assert.equal(error.status, 400); + assert.deepEqual(error.body, { error: 'invalid' }); + assert.equal(error.request.method, 'GET'); + return true; + } + ); +}); + +test('HttpClient respects custom expected status codes', async () => { + const fetchStub = createFetchStub([{ status: 202 }]); + const client = new HttpClient({ authToken: 'token', fetch: fetchStub }); + + await assert.doesNotReject( + client.request('POST', 'resources', { expectedStatuses: 202 }) + ); +}); + +test('HttpClient propagates timeout errors as PushpadError', async () => { + let abortSignal; + const fetchStub = async (_url, options = {}) => { + abortSignal = options.signal; + return new Promise((resolve, reject) => { + options.signal?.addEventListener('abort', () => { + const error = new Error('Aborted'); + error.name = 'AbortError'; + reject(error); + }); + }); + }; + + const client = new HttpClient({ authToken: 'token', fetch: fetchStub, timeout: 10 }); + + await assert.rejects( + client.request('GET', 'will-timeout'), + (error) => { + assert(error instanceof PushpadError); + assert.equal(error.message, 'Request timed out'); + assert.equal(error.status, 0); + return true; + } + ); + + assert(abortSignal.aborted); +}); diff --git a/test/notifications.test.js b/test/notifications.test.js new file mode 100644 index 0000000..70c2415 --- /dev/null +++ b/test/notifications.test.js @@ -0,0 +1,46 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import Pushpad from '../src/index.js'; +import { createFetchStub } from './helpers/fetchStub.js'; +import { parseLastCall } from './helpers/inspectFetch.js'; + +test('notification.create posts to the project notifications endpoint', async () => { + const fetchStub = createFetchStub([ + { status: 201, body: { id: 99 } } + ]); + + const client = new Pushpad({ authToken: 'token', projectId: 42, fetch: fetchStub }); + const response = await client.notification.create({ body: 'Hello world' }); + + assert.deepEqual(response, { id: 99 }); + const { call, url } = parseLastCall(fetchStub); + assert.equal(call.options.method, 'POST'); + assert.equal(url.pathname, '/api/v1/projects/42/notifications'); + assert.equal(call.options.headers.get('Authorization'), 'Bearer token'); + assert.equal(call.options.body, JSON.stringify({ body: 'Hello world' })); +}); + +test('notification.findAll supports overriding projectId per call', async () => { + const fetchStub = createFetchStub([ + { status: 200, body: [] } + ]); + + const client = new Pushpad({ authToken: 'token', projectId: 1, fetch: fetchStub }); + await client.notification.findAll({ page: 3 }, { projectId: 77 }); + + const { url } = parseLastCall(fetchStub); + assert.equal(url.pathname, '/api/v1/projects/77/notifications'); + assert.equal(url.searchParams.get('page'), '3'); +}); + +test('notification.cancel issues DELETE without body', async () => { + const fetchStub = createFetchStub([{ status: 204 }]); + const client = new Pushpad({ authToken: 'token', projectId: 7, fetch: fetchStub }); + + await client.notification.cancel(888); + + const { call, url } = parseLastCall(fetchStub); + assert.equal(call.options.method, 'DELETE'); + assert.equal(url.pathname, '/api/v1/notifications/888/cancel'); + assert.equal(call.options.body, undefined); +}); diff --git a/test/projects.test.js b/test/projects.test.js new file mode 100644 index 0000000..6e77df2 --- /dev/null +++ b/test/projects.test.js @@ -0,0 +1,86 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import Pushpad from '../src/index.js'; +import { createFetchStub } from './helpers/fetchStub.js'; +import { parseLastCall } from './helpers/inspectFetch.js'; + +test('project.create posts project definition to /projects', async () => { + const projectPayload = { + sender_id: 98765, + name: 'My Project', + website: 'https://example.com', + icon_url: 'https://example.com/icon.png', + badge_url: 'https://example.com/badge.png' + }; + + const projectResponse = { + id: 12345, + created_at: '2025-09-14T10:30:00.123Z', + ...projectPayload + }; + + const fetchStub = createFetchStub([{ status: 201, body: projectResponse }]); + const client = new Pushpad({ authToken: 'token', fetch: fetchStub }); + + const result = await client.project.create(projectPayload); + + assert.deepEqual(result, projectResponse); + const { call, url } = parseLastCall(fetchStub); + assert.equal(call.options.method, 'POST'); + assert.equal(url.pathname, '/api/v1/projects'); + assert.equal(call.options.body, JSON.stringify(projectPayload)); +}); + +test('project.findAll retrieves all projects', async () => { + const projects = [ + { id: 1, name: 'Project A', website: 'https://a.test', sender_id: 5 } + ]; + const fetchStub = createFetchStub([{ status: 200, body: projects }]); + const client = new Pushpad({ authToken: 'token', fetch: fetchStub }); + + const result = await client.project.findAll(); + + assert.deepEqual(result, projects); + const { call, url } = parseLastCall(fetchStub); + assert.equal(call.options.method, 'GET'); + assert.equal(url.pathname, '/api/v1/projects'); +}); + +test('project.find fetches project by id', async () => { + const project = { id: 99, name: 'Sample', website: 'https://sample.test', sender_id: 3 }; + const fetchStub = createFetchStub([{ status: 200, body: project }]); + const client = new Pushpad({ authToken: 'token', fetch: fetchStub }); + + const result = await client.project.find(99); + + assert.deepEqual(result, project); + const { call, url } = parseLastCall(fetchStub); + assert.equal(call.options.method, 'GET'); + assert.equal(url.pathname, '/api/v1/projects/99'); +}); + +test('project.update patches existing project', async () => { + const updatePayload = { name: 'Updated Project', notifications_ttl: 604800 }; + const response = { id: 44, ...updatePayload }; + const fetchStub = createFetchStub([{ status: 200, body: response }]); + const client = new Pushpad({ authToken: 'token', fetch: fetchStub }); + + const result = await client.project.update(44, updatePayload); + + assert.deepEqual(result, response); + const { call, url } = parseLastCall(fetchStub); + assert.equal(call.options.method, 'PATCH'); + assert.equal(url.pathname, '/api/v1/projects/44'); + assert.equal(call.options.body, JSON.stringify(updatePayload)); +}); + +test('project.delete sends DELETE and expects 202 status', async () => { + const fetchStub = createFetchStub([{ status: 202 }]); + const client = new Pushpad({ authToken: 'token', fetch: fetchStub }); + + await client.project.delete(51); + + const { call, url } = parseLastCall(fetchStub); + assert.equal(call.options.method, 'DELETE'); + assert.equal(url.pathname, '/api/v1/projects/51'); +}); diff --git a/test/senders.test.js b/test/senders.test.js new file mode 100644 index 0000000..ee2b027 --- /dev/null +++ b/test/senders.test.js @@ -0,0 +1,79 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import Pushpad from '../src/index.js'; +import { createFetchStub } from './helpers/fetchStub.js'; +import { parseLastCall } from './helpers/inspectFetch.js'; + +test('sender.create posts to /senders', async () => { + const senderPayload = { name: 'My Sender' }; + const senderResponse = { + id: 321, + vapid_private_key: '-----BEGIN EC PRIVATE KEY----- ...', + vapid_public_key: '-----BEGIN PUBLIC KEY----- ...', + created_at: '2025-09-13T10:30:00.123Z', + ...senderPayload + }; + + const fetchStub = createFetchStub([{ status: 201, body: senderResponse }]); + const client = new Pushpad({ authToken: 'token', fetch: fetchStub }); + + const result = await client.sender.create(senderPayload); + + assert.deepEqual(result, senderResponse); + const { call, url } = parseLastCall(fetchStub); + assert.equal(call.options.method, 'POST'); + assert.equal(url.pathname, '/api/v1/senders'); + assert.equal(call.options.body, JSON.stringify(senderPayload)); +}); + +test('sender.findAll lists senders', async () => { + const senders = [{ id: 1, name: 'Sender A' }]; + const fetchStub = createFetchStub([{ status: 200, body: senders }]); + const client = new Pushpad({ authToken: 'token', fetch: fetchStub }); + + const result = await client.sender.findAll(); + + assert.deepEqual(result, senders); + const { call, url } = parseLastCall(fetchStub); + assert.equal(call.options.method, 'GET'); + assert.equal(url.pathname, '/api/v1/senders'); +}); + +test('sender.find retrieves sender by id', async () => { + const sender = { id: 77, name: 'Sender B' }; + const fetchStub = createFetchStub([{ status: 200, body: sender }]); + const client = new Pushpad({ authToken: 'token', fetch: fetchStub }); + + const result = await client.sender.find(77); + + assert.deepEqual(result, sender); + const { call, url } = parseLastCall(fetchStub); + assert.equal(call.options.method, 'GET'); + assert.equal(url.pathname, '/api/v1/senders/77'); +}); + +test('sender.update patches sender resource', async () => { + const updatePayload = { name: 'Updated Sender' }; + const response = { id: 5, name: 'Updated Sender' }; + const fetchStub = createFetchStub([{ status: 200, body: response }]); + const client = new Pushpad({ authToken: 'token', fetch: fetchStub }); + + const result = await client.sender.update(5, updatePayload); + + assert.deepEqual(result, response); + const { call, url } = parseLastCall(fetchStub); + assert.equal(call.options.method, 'PATCH'); + assert.equal(url.pathname, '/api/v1/senders/5'); + assert.equal(call.options.body, JSON.stringify(updatePayload)); +}); + +test('sender.delete sends DELETE to sender resource', async () => { + const fetchStub = createFetchStub([{ status: 204 }]); + const client = new Pushpad({ authToken: 'token', fetch: fetchStub }); + + await client.sender.delete(333); + + const { call, url } = parseLastCall(fetchStub); + assert.equal(call.options.method, 'DELETE'); + assert.equal(url.pathname, '/api/v1/senders/333'); +}); diff --git a/test/subscriptions.test.js b/test/subscriptions.test.js new file mode 100644 index 0000000..35a3f02 --- /dev/null +++ b/test/subscriptions.test.js @@ -0,0 +1,98 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import Pushpad from '../src/index.js'; +import { createFetchStub } from './helpers/fetchStub.js'; +import { parseLastCall } from './helpers/inspectFetch.js'; + +test('subscription.findAll normalises query params', async () => { + const fetchStub = createFetchStub([{ status: 200, body: [] }]); + const client = new Pushpad({ authToken: 'token', projectId: 55, fetch: fetchStub }); + + await client.subscription.findAll({ perPage: 10, uids: 'user1', tags: ['a', 'b'] }); + + const { url } = parseLastCall(fetchStub); + assert.equal(url.pathname, '/api/v1/projects/55/subscriptions'); + assert.equal(url.searchParams.get('per_page'), '10'); + assert.deepEqual(url.searchParams.getAll('uids[]'), ['user1']); + assert.deepEqual(url.searchParams.getAll('tags[]'), ['a', 'b']); +}); + +test('subscription.create sends payload to project route', async () => { + const subscriptionPayload = { + endpoint: 'https://example.com/push/f7Q1Eyf7EyfAb1', + p256dh: 'BCQVDTlYWdl05lal3lG5SKr3VxTrEWpZErbkxWrzknHrIKFwihDoZpc_2sH6Sh08h-CacUYI-H8gW4jH-uMYZQ4=', + auth: 'cdKMlhgVeSPzCXZ3V7FtgQ==', + uid: 'user1', + tags: ['tag1', 'tag2'] + }; + + const subscriptionResponse = { + id: 12345, + project_id: 55, + ...subscriptionPayload, + last_click_at: null, + created_at: '2025-09-15T10:30:00.123Z' + }; + + const fetchStub = createFetchStub([{ status: 201, body: subscriptionResponse }]); + const client = new Pushpad({ authToken: 'token', projectId: 55, fetch: fetchStub }); + + const result = await client.subscription.create(subscriptionPayload); + + assert.deepEqual(result, subscriptionResponse); + const { call, url } = parseLastCall(fetchStub); + assert.equal(call.options.method, 'POST'); + assert.equal(url.pathname, '/api/v1/projects/55/subscriptions'); + assert.equal(call.options.body, JSON.stringify(subscriptionPayload)); +}); + +test('subscription.update patches subscription resource', async () => { + const fetchStub = createFetchStub([{ status: 200, body: { id: 99, uid: 'user2' } }]); + const client = new Pushpad({ authToken: 'token', projectId: 101, fetch: fetchStub }); + + const updatePayload = { uid: 'user2', tags: ['vip'] }; + const response = await client.subscription.update(4321, updatePayload); + + assert.deepEqual(response, { id: 99, uid: 'user2' }); + const { call, url } = parseLastCall(fetchStub); + assert.equal(call.options.method, 'PATCH'); + assert.equal(url.pathname, '/api/v1/projects/101/subscriptions/4321'); + assert.equal(call.options.body, JSON.stringify(updatePayload)); +}); + +test('subscription.delete removes subscription resource', async () => { + const fetchStub = createFetchStub([{ status: 204 }]); + const client = new Pushpad({ authToken: 'token', projectId: 502, fetch: fetchStub }); + + await client.subscription.delete(777); + + const { call, url } = parseLastCall(fetchStub); + assert.equal(call.options.method, 'DELETE'); + assert.equal(url.pathname, '/api/v1/projects/502/subscriptions/777'); + assert.equal(call.options.body, undefined); +}); + +test('subscription.find requires a project id', async () => { + const fetchStub = createFetchStub(); + const client = new Pushpad({ authToken: 'token', fetch: fetchStub }); + + await assert.rejects( + client.subscription.find(1), + (error) => { + assert(error instanceof Error); + assert.match(error.message, /projectId is required/); + return true; + } + ); +}); + +test('setProjectId updates the default project used for calls', async () => { + const fetchStub = createFetchStub([{ status: 200, body: [] }]); + const client = new Pushpad({ authToken: 'token', projectId: 10, fetch: fetchStub }); + + client.setProjectId(1234); + await client.subscription.findAll(); + + const { url } = parseLastCall(fetchStub); + assert.equal(url.pathname, '/api/v1/projects/1234/subscriptions'); +}); From 965e1b58459888a44c3e6aa70d516b3f707c4139 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Thu, 23 Oct 2025 15:27:51 +0200 Subject: [PATCH 03/25] Remove setter and getter for projectId on client (to make it immutable) --- index.d.ts | 2 -- src/index.js | 14 -------------- test/subscriptions.test.js | 11 ----------- 3 files changed, 27 deletions(-) diff --git a/index.d.ts b/index.d.ts index a1fe1ef..20aade4 100644 --- a/index.d.ts +++ b/index.d.ts @@ -143,8 +143,6 @@ export class Pushpad { subscription: SubscriptionResource; project: ProjectResource; sender: SenderResource; - get projectId(): number | string | undefined; - setProjectId(projectId?: number | string): void; } export class PushpadError extends Error { diff --git a/src/index.js b/src/index.js index aba7e25..acfc76a 100644 --- a/src/index.js +++ b/src/index.js @@ -49,20 +49,6 @@ export class Pushpad { this.sender = new SenderResource(this.client, getProjectId); } - /** - * Overrides the default project id used for project-scoped endpoints. - * @param {number | string | undefined} projectId - */ - setProjectId(projectId) { - this._defaultProjectId = projectId ?? undefined; - } - - /** - * @returns {number | string | undefined} - */ - get projectId() { - return this._defaultProjectId; - } } export { PushpadError }; diff --git a/test/subscriptions.test.js b/test/subscriptions.test.js index 35a3f02..dfbc6a2 100644 --- a/test/subscriptions.test.js +++ b/test/subscriptions.test.js @@ -85,14 +85,3 @@ test('subscription.find requires a project id', async () => { } ); }); - -test('setProjectId updates the default project used for calls', async () => { - const fetchStub = createFetchStub([{ status: 200, body: [] }]); - const client = new Pushpad({ authToken: 'token', projectId: 10, fetch: fetchStub }); - - client.setProjectId(1234); - await client.subscription.findAll(); - - const { url } = parseLastCall(fetchStub); - assert.equal(url.pathname, '/api/v1/projects/1234/subscriptions'); -}); From 30b4730bc7a2c864b73486eb76b0710a7f277f1a Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Thu, 23 Oct 2025 16:31:05 +0200 Subject: [PATCH 04/25] Refactor: pass the default project id to resources (instead of a function) --- src/index.js | 17 ++++------------- src/resources/base.js | 9 +++++---- src/resources/projects.js | 3 --- src/resources/senders.js | 3 --- 4 files changed, 9 insertions(+), 23 deletions(-) diff --git a/src/index.js b/src/index.js index acfc76a..ac956fc 100644 --- a/src/index.js +++ b/src/index.js @@ -32,21 +32,12 @@ export class Pushpad { throw new Error('authToken is required to initialise Pushpad.'); } - this._defaultProjectId = projectId ?? undefined; - this.client = new HttpClient({ authToken, baseUrl, fetch, timeout }); - const getProjectId = (opts) => { - if (opts && Object.prototype.hasOwnProperty.call(opts, 'projectId')) { - return opts.projectId; - } - return this._defaultProjectId; - }; - - this.notification = new NotificationResource(this.client, getProjectId); - this.subscription = new SubscriptionResource(this.client, getProjectId); - this.project = new ProjectResource(this.client, getProjectId); - this.sender = new SenderResource(this.client, getProjectId); + this.notification = new NotificationResource(this.client, projectId); + this.subscription = new SubscriptionResource(this.client, projectId); + this.project = new ProjectResource(this.client, projectId); + this.sender = new SenderResource(this.client, projectId); } } diff --git a/src/resources/base.js b/src/resources/base.js index a244ba3..5496dec 100644 --- a/src/resources/base.js +++ b/src/resources/base.js @@ -4,11 +4,11 @@ export class ResourceBase { /** * @param {import('../httpClient.js').HttpClient} client - * @param {(options?: { projectId?: number | string }) => (number | string | undefined)} getProjectId + * @param {number | string | undefined} defaultProjectId */ - constructor(client, getProjectId) { + constructor(client, defaultProjectId) { this.client = client; - this.getProjectId = getProjectId; + this.defaultProjectId = defaultProjectId; } /** @@ -17,7 +17,8 @@ export class ResourceBase { * @returns {string} */ requireProjectId(options) { - const projectId = this.getProjectId?.(options); + const hasOverride = options && Object.prototype.hasOwnProperty.call(options, 'projectId'); + const projectId = hasOverride ? options.projectId : this.defaultProjectId; if (projectId === undefined || projectId === null || projectId === '') { throw new Error('A projectId is required. Pass it in the Pushpad constructor or the call options.'); } diff --git a/src/resources/projects.js b/src/resources/projects.js index d2c186a..c4eac11 100644 --- a/src/resources/projects.js +++ b/src/resources/projects.js @@ -4,9 +4,6 @@ import { ResourceBase } from './base.js'; * Provides access to Pushpad project endpoints. */ export class ProjectResource extends ResourceBase { - constructor(client, getProjectId) { - super(client, getProjectId); - } /** * Creates a new project. diff --git a/src/resources/senders.js b/src/resources/senders.js index e270071..75aeb8b 100644 --- a/src/resources/senders.js +++ b/src/resources/senders.js @@ -4,9 +4,6 @@ import { ResourceBase } from './base.js'; * Provides access to Pushpad sender endpoints. */ export class SenderResource extends ResourceBase { - constructor(client, getProjectId) { - super(client, getProjectId); - } /** * Creates a new sender. From 48a2ac2b533bd790ac65bd67ea531c9e831aa84e Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Thu, 23 Oct 2025 17:12:14 +0200 Subject: [PATCH 05/25] Improve tests --- test/notifications.test.js | 66 +++++++++++++++++++++- test/projects.test.js | 42 ++++++++++++-- test/senders.test.js | 42 ++++++++++++-- test/subscriptions.test.js | 109 ++++++++++++++++++++++++++++++------- 4 files changed, 226 insertions(+), 33 deletions(-) diff --git a/test/notifications.test.js b/test/notifications.test.js index 70c2415..f669e8c 100644 --- a/test/notifications.test.js +++ b/test/notifications.test.js @@ -4,7 +4,7 @@ import Pushpad from '../src/index.js'; import { createFetchStub } from './helpers/fetchStub.js'; import { parseLastCall } from './helpers/inspectFetch.js'; -test('notification.create posts to the project notifications endpoint', async () => { +test('notification.create creates a notification', async () => { const fetchStub = createFetchStub([ { status: 201, body: { id: 99 } } ]); @@ -20,7 +20,21 @@ test('notification.create posts to the project notifications endpoint', async () assert.equal(call.options.body, JSON.stringify({ body: 'Hello world' })); }); -test('notification.findAll supports overriding projectId per call', async () => { +test('notification.findAll lists notifications for the default project', async () => { + const fetchStub = createFetchStub([ + { status: 200, body: [{ id: 1 }, { id: 2 }] } + ]); + + const client = new Pushpad({ authToken: 'token', projectId: 42, fetch: fetchStub }); + const response = await client.notification.findAll(); + + assert.deepEqual(response, [{ id: 1 }, { id: 2 }]); + const { call, url } = parseLastCall(fetchStub); + assert.equal(call.options.method, 'GET'); + assert.equal(url.pathname, '/api/v1/projects/42/notifications'); +}); + +test('notification.findAll accepts per-call project overrides', async () => { const fetchStub = createFetchStub([ { status: 200, body: [] } ]); @@ -33,7 +47,21 @@ test('notification.findAll supports overriding projectId per call', async () => assert.equal(url.searchParams.get('page'), '3'); }); -test('notification.cancel issues DELETE without body', async () => { +test('notification.find retrieves a notification', async () => { + const fetchStub = createFetchStub([ + { status: 200, body: { id: 5, body: 'Hello world' } } + ]); + + const client = new Pushpad({ authToken: 'token', fetch: fetchStub }); + const response = await client.notification.find(5); + + assert.deepEqual(response, { id: 5, body: 'Hello world' }); + const { call, url } = parseLastCall(fetchStub); + assert.equal(call.options.method, 'GET'); + assert.equal(url.pathname, '/api/v1/notifications/5'); +}); + +test('notification.cancel cancels a notification', async () => { const fetchStub = createFetchStub([{ status: 204 }]); const client = new Pushpad({ authToken: 'token', projectId: 7, fetch: fetchStub }); @@ -44,3 +72,35 @@ test('notification.cancel issues DELETE without body', async () => { assert.equal(url.pathname, '/api/v1/notifications/888/cancel'); assert.equal(call.options.body, undefined); }); + +test('notification.create requires an object payload', async () => { + const fetchStub = createFetchStub(); + const client = new Pushpad({ authToken: 'token', projectId: 9, fetch: fetchStub }); + + await assert.rejects( + client.notification.create(), + (error) => { + assert(error instanceof Error); + assert.match(error.message, /non-empty object/); + return true; + } + ); + + assert.equal(fetchStub.calls.length, 0); +}); + +test('notification.create requires a project id', async () => { + const fetchStub = createFetchStub(); + const client = new Pushpad({ authToken: 'token', fetch: fetchStub }); + + await assert.rejects( + client.notification.create({ body: 'Hello' }), + (error) => { + assert(error instanceof Error); + assert.match(error.message, /projectId is required/); + return true; + } + ); + + assert.equal(fetchStub.calls.length, 0); +}); diff --git a/test/projects.test.js b/test/projects.test.js index 6e77df2..8103154 100644 --- a/test/projects.test.js +++ b/test/projects.test.js @@ -4,7 +4,7 @@ import Pushpad from '../src/index.js'; import { createFetchStub } from './helpers/fetchStub.js'; import { parseLastCall } from './helpers/inspectFetch.js'; -test('project.create posts project definition to /projects', async () => { +test('project.create creates a project', async () => { const projectPayload = { sender_id: 98765, name: 'My Project', @@ -31,7 +31,7 @@ test('project.create posts project definition to /projects', async () => { assert.equal(call.options.body, JSON.stringify(projectPayload)); }); -test('project.findAll retrieves all projects', async () => { +test('project.findAll lists projects', async () => { const projects = [ { id: 1, name: 'Project A', website: 'https://a.test', sender_id: 5 } ]; @@ -46,7 +46,7 @@ test('project.findAll retrieves all projects', async () => { assert.equal(url.pathname, '/api/v1/projects'); }); -test('project.find fetches project by id', async () => { +test('project.find retrieves a project', async () => { const project = { id: 99, name: 'Sample', website: 'https://sample.test', sender_id: 3 }; const fetchStub = createFetchStub([{ status: 200, body: project }]); const client = new Pushpad({ authToken: 'token', fetch: fetchStub }); @@ -59,7 +59,7 @@ test('project.find fetches project by id', async () => { assert.equal(url.pathname, '/api/v1/projects/99'); }); -test('project.update patches existing project', async () => { +test('project.update updates a project', async () => { const updatePayload = { name: 'Updated Project', notifications_ttl: 604800 }; const response = { id: 44, ...updatePayload }; const fetchStub = createFetchStub([{ status: 200, body: response }]); @@ -74,7 +74,7 @@ test('project.update patches existing project', async () => { assert.equal(call.options.body, JSON.stringify(updatePayload)); }); -test('project.delete sends DELETE and expects 202 status', async () => { +test('project.delete deletes a project', async () => { const fetchStub = createFetchStub([{ status: 202 }]); const client = new Pushpad({ authToken: 'token', fetch: fetchStub }); @@ -84,3 +84,35 @@ test('project.delete sends DELETE and expects 202 status', async () => { assert.equal(call.options.method, 'DELETE'); assert.equal(url.pathname, '/api/v1/projects/51'); }); + +test('project.create requires an object payload', async () => { + const fetchStub = createFetchStub(); + const client = new Pushpad({ authToken: 'token', fetch: fetchStub }); + + await assert.rejects( + client.project.create(), + (error) => { + assert(error instanceof Error); + assert.match(error.message, /non-empty object/); + return true; + } + ); + + assert.equal(fetchStub.calls.length, 0); +}); + +test('project.update requires an object payload', async () => { + const fetchStub = createFetchStub(); + const client = new Pushpad({ authToken: 'token', fetch: fetchStub }); + + await assert.rejects( + client.project.update(1), + (error) => { + assert(error instanceof Error); + assert.match(error.message, /non-empty object/); + return true; + } + ); + + assert.equal(fetchStub.calls.length, 0); +}); diff --git a/test/senders.test.js b/test/senders.test.js index ee2b027..a97c6ec 100644 --- a/test/senders.test.js +++ b/test/senders.test.js @@ -4,7 +4,7 @@ import Pushpad from '../src/index.js'; import { createFetchStub } from './helpers/fetchStub.js'; import { parseLastCall } from './helpers/inspectFetch.js'; -test('sender.create posts to /senders', async () => { +test('sender.create creates a sender', async () => { const senderPayload = { name: 'My Sender' }; const senderResponse = { id: 321, @@ -26,7 +26,7 @@ test('sender.create posts to /senders', async () => { assert.equal(call.options.body, JSON.stringify(senderPayload)); }); -test('sender.findAll lists senders', async () => { +test('sender.findAll retrieves senders', async () => { const senders = [{ id: 1, name: 'Sender A' }]; const fetchStub = createFetchStub([{ status: 200, body: senders }]); const client = new Pushpad({ authToken: 'token', fetch: fetchStub }); @@ -39,7 +39,7 @@ test('sender.findAll lists senders', async () => { assert.equal(url.pathname, '/api/v1/senders'); }); -test('sender.find retrieves sender by id', async () => { +test('sender.find retrieves a sender', async () => { const sender = { id: 77, name: 'Sender B' }; const fetchStub = createFetchStub([{ status: 200, body: sender }]); const client = new Pushpad({ authToken: 'token', fetch: fetchStub }); @@ -52,7 +52,7 @@ test('sender.find retrieves sender by id', async () => { assert.equal(url.pathname, '/api/v1/senders/77'); }); -test('sender.update patches sender resource', async () => { +test('sender.update updates a sender', async () => { const updatePayload = { name: 'Updated Sender' }; const response = { id: 5, name: 'Updated Sender' }; const fetchStub = createFetchStub([{ status: 200, body: response }]); @@ -67,7 +67,7 @@ test('sender.update patches sender resource', async () => { assert.equal(call.options.body, JSON.stringify(updatePayload)); }); -test('sender.delete sends DELETE to sender resource', async () => { +test('sender.delete deletes a sender', async () => { const fetchStub = createFetchStub([{ status: 204 }]); const client = new Pushpad({ authToken: 'token', fetch: fetchStub }); @@ -77,3 +77,35 @@ test('sender.delete sends DELETE to sender resource', async () => { assert.equal(call.options.method, 'DELETE'); assert.equal(url.pathname, '/api/v1/senders/333'); }); + +test('sender.create requires an object payload', async () => { + const fetchStub = createFetchStub(); + const client = new Pushpad({ authToken: 'token', fetch: fetchStub }); + + await assert.rejects( + client.sender.create(), + (error) => { + assert(error instanceof Error); + assert.match(error.message, /non-empty object/); + return true; + } + ); + + assert.equal(fetchStub.calls.length, 0); +}); + +test('sender.update requires an object payload', async () => { + const fetchStub = createFetchStub(); + const client = new Pushpad({ authToken: 'token', fetch: fetchStub }); + + await assert.rejects( + client.sender.update(1), + (error) => { + assert(error instanceof Error); + assert.match(error.message, /non-empty object/); + return true; + } + ); + + assert.equal(fetchStub.calls.length, 0); +}); diff --git a/test/subscriptions.test.js b/test/subscriptions.test.js index dfbc6a2..f28e56a 100644 --- a/test/subscriptions.test.js +++ b/test/subscriptions.test.js @@ -4,20 +4,7 @@ import Pushpad from '../src/index.js'; import { createFetchStub } from './helpers/fetchStub.js'; import { parseLastCall } from './helpers/inspectFetch.js'; -test('subscription.findAll normalises query params', async () => { - const fetchStub = createFetchStub([{ status: 200, body: [] }]); - const client = new Pushpad({ authToken: 'token', projectId: 55, fetch: fetchStub }); - - await client.subscription.findAll({ perPage: 10, uids: 'user1', tags: ['a', 'b'] }); - - const { url } = parseLastCall(fetchStub); - assert.equal(url.pathname, '/api/v1/projects/55/subscriptions'); - assert.equal(url.searchParams.get('per_page'), '10'); - assert.deepEqual(url.searchParams.getAll('uids[]'), ['user1']); - assert.deepEqual(url.searchParams.getAll('tags[]'), ['a', 'b']); -}); - -test('subscription.create sends payload to project route', async () => { +test('subscription.create creates a subscription', async () => { const subscriptionPayload = { endpoint: 'https://example.com/push/f7Q1Eyf7EyfAb1', p256dh: 'BCQVDTlYWdl05lal3lG5SKr3VxTrEWpZErbkxWrzknHrIKFwihDoZpc_2sH6Sh08h-CacUYI-H8gW4jH-uMYZQ4=', @@ -46,7 +33,71 @@ test('subscription.create sends payload to project route', async () => { assert.equal(call.options.body, JSON.stringify(subscriptionPayload)); }); -test('subscription.update patches subscription resource', async () => { +test('subscription.findAll lists subscriptions for a project', async () => { + const fetchStub = createFetchStub([{ status: 200, body: [{ id: 1 }, { id: 2 }] }]); + const client = new Pushpad({ authToken: 'token', projectId: 55, fetch: fetchStub }); + + const response = await client.subscription.findAll(); + + assert.deepEqual(response, [{ id: 1 }, { id: 2 }]); + const { call, url } = parseLastCall(fetchStub); + assert.equal(call.options.method, 'GET'); + assert.equal(url.pathname, '/api/v1/projects/55/subscriptions'); +}); + +test('subscription.findAll normalises query params', async () => { + const fetchStub = createFetchStub([{ status: 200, body: [] }]); + const client = new Pushpad({ authToken: 'token', projectId: 55, fetch: fetchStub }); + + await client.subscription.findAll({ perPage: 10, uids: 'user1', tags: ['a', 'b'] }); + + const { url } = parseLastCall(fetchStub); + assert.equal(url.pathname, '/api/v1/projects/55/subscriptions'); + assert.equal(url.searchParams.get('per_page'), '10'); + assert.deepEqual(url.searchParams.getAll('uids[]'), ['user1']); + assert.deepEqual(url.searchParams.getAll('tags[]'), ['a', 'b']); +}); + +test('subscription.find retrieves a subscription', async () => { + const subscriptionResponse = { id: 999, uid: 'user1', tags: ['a'] }; + const fetchStub = createFetchStub([{ status: 200, body: subscriptionResponse }]); + const client = new Pushpad({ authToken: 'token', projectId: 12, fetch: fetchStub }); + + const response = await client.subscription.find(999); + + assert.deepEqual(response, subscriptionResponse); + const { call, url } = parseLastCall(fetchStub); + assert.equal(call.options.method, 'GET'); + assert.equal(url.pathname, '/api/v1/projects/12/subscriptions/999'); +}); + +test('subscription.find allows overriding projectId per call', async () => { + const subscriptionResponse = { id: 321, uid: 'user2' }; + const fetchStub = createFetchStub([{ status: 200, body: subscriptionResponse }]); + const client = new Pushpad({ authToken: 'token', fetch: fetchStub }); + + const response = await client.subscription.find(321, { projectId: 44 }); + + assert.deepEqual(response, subscriptionResponse); + const { call, url } = parseLastCall(fetchStub); + assert.equal(url.pathname, '/api/v1/projects/44/subscriptions/321'); +}); + +test('subscription.find requires a project id', async () => { + const fetchStub = createFetchStub(); + const client = new Pushpad({ authToken: 'token', fetch: fetchStub }); + + await assert.rejects( + client.subscription.find(1), + (error) => { + assert(error instanceof Error); + assert.match(error.message, /projectId is required/); + return true; + } + ); +}); + +test('subscription.update updates a subscription', async () => { const fetchStub = createFetchStub([{ status: 200, body: { id: 99, uid: 'user2' } }]); const client = new Pushpad({ authToken: 'token', projectId: 101, fetch: fetchStub }); @@ -60,7 +111,7 @@ test('subscription.update patches subscription resource', async () => { assert.equal(call.options.body, JSON.stringify(updatePayload)); }); -test('subscription.delete removes subscription resource', async () => { +test('subscription.delete deletes a subscription', async () => { const fetchStub = createFetchStub([{ status: 204 }]); const client = new Pushpad({ authToken: 'token', projectId: 502, fetch: fetchStub }); @@ -72,16 +123,34 @@ test('subscription.delete removes subscription resource', async () => { assert.equal(call.options.body, undefined); }); -test('subscription.find requires a project id', async () => { +test('subscription.create requires an object payload', async () => { const fetchStub = createFetchStub(); - const client = new Pushpad({ authToken: 'token', fetch: fetchStub }); + const client = new Pushpad({ authToken: 'token', projectId: 88, fetch: fetchStub }); await assert.rejects( - client.subscription.find(1), + client.subscription.create(), (error) => { assert(error instanceof Error); - assert.match(error.message, /projectId is required/); + assert.match(error.message, /non-empty object/); + return true; + } + ); + + assert.equal(fetchStub.calls.length, 0); +}); + +test('subscription.update requires an object payload', async () => { + const fetchStub = createFetchStub(); + const client = new Pushpad({ authToken: 'token', projectId: 91, fetch: fetchStub }); + + await assert.rejects( + client.subscription.update(5), + (error) => { + assert(error instanceof Error); + assert.match(error.message, /non-empty object/); return true; } ); + + assert.equal(fetchStub.calls.length, 0); }); From a2cb93b20be2f4646dc26a0a8951c63cca89e03f Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Mon, 27 Oct 2025 12:12:03 +0100 Subject: [PATCH 06/25] Fix and improve typescript definitions --- index.d.ts | 76 +++++++++++++++++++++++++++++++++--------------------- 1 file changed, 47 insertions(+), 29 deletions(-) diff --git a/index.d.ts b/index.d.ts index 20aade4..2d6041f 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,3 +1,10 @@ +export interface NotificationAction { + title?: string; + target_url?: string; + icon?: string; + action?: string; +} + export interface Notification { id?: number; project_id?: number; @@ -10,15 +17,26 @@ export interface Notification { ttl?: number; require_interaction?: boolean; silent?: boolean; - custom_data?: Record; + urgent?: boolean; + custom_data?: string; + actions?: NotificationAction[]; + starred?: boolean; send_at?: string; - click_url?: string; - custom_metrics?: unknown; + custom_metrics?: string[]; + uids?: string[]; + tags?: string[]; created_at?: string; - [key: string]: unknown; + successfully_sent_count?: number; + opened_count?: number; + scheduled_count?: number; + scheduled?: boolean; + cancelled?: boolean; } -export interface NotificationCreateInput extends Omit { +export interface NotificationCreateInput extends Omit< + Notification, + 'id' | 'project_id' | 'created_at' | 'successfully_sent_count' | 'opened_count' | 'scheduled_count' | 'scheduled' | 'cancelled' +> { body: string; } @@ -27,7 +45,6 @@ export interface NotificationCreateResult { scheduled?: number; uids?: string[]; send_at?: string; - [key: string]: unknown; } export interface NotificationListQuery { @@ -42,23 +59,24 @@ export interface Subscription { auth?: string; uid?: string; tags?: string[]; - last_click_at?: string | null; + last_click_at?: string; created_at?: string; - [key: string]: unknown; } export interface SubscriptionCreateInput extends Omit { endpoint: string; } -export interface SubscriptionUpdateInput extends Partial {} +export interface SubscriptionUpdateInput extends Partial< + Omit +> {} export interface SubscriptionListQuery { page?: number; per_page?: number; perPage?: number; - uids?: string | string[]; - tags?: string | string[]; + uids?: string[]; + tags?: string[]; } export interface Project { @@ -72,13 +90,12 @@ export interface Project { notifications_require_interaction?: boolean; notifications_silent?: boolean; created_at?: string; - [key: string]: unknown; } export interface ProjectCreateInput extends Required>, Omit {} -export interface ProjectUpdateInput extends Partial {} +export interface ProjectUpdateInput extends Partial> {} export interface Sender { id?: number; @@ -86,21 +103,22 @@ export interface Sender { vapid_private_key?: string; vapid_public_key?: string; created_at?: string; - [key: string]: unknown; } export interface SenderCreateInput extends Required>, Omit {} -export interface SenderUpdateInput extends Partial {} +export interface SenderUpdateInput extends Partial< + Omit +> {} export interface RequestOptions { - projectId?: number | string; + projectId?: number; } export interface PushpadOptions { authToken: string; - projectId?: number | string; + projectId?: number; baseUrl?: string; fetch?: typeof fetch; timeout?: number; @@ -109,32 +127,32 @@ export interface PushpadOptions { export class NotificationResource { create(data: NotificationCreateInput, options?: RequestOptions): Promise; findAll(query?: NotificationListQuery, options?: RequestOptions): Promise; - find(notificationId: number | string): Promise; - cancel(notificationId: number | string): Promise; + find(notificationId: number): Promise; + cancel(notificationId: number): Promise; } export class SubscriptionResource { create(data: SubscriptionCreateInput, options?: RequestOptions): Promise; findAll(query?: SubscriptionListQuery, options?: RequestOptions): Promise; - find(subscriptionId: number | string, options?: RequestOptions): Promise; - update(subscriptionId: number | string, data: SubscriptionUpdateInput, options?: RequestOptions): Promise; - delete(subscriptionId: number | string, options?: RequestOptions): Promise; + find(subscriptionId: number, options?: RequestOptions): Promise; + update(subscriptionId: number, data: SubscriptionUpdateInput, options?: RequestOptions): Promise; + delete(subscriptionId: number, options?: RequestOptions): Promise; } export class ProjectResource { create(data: ProjectCreateInput): Promise; findAll(): Promise; - find(projectId: number | string): Promise; - update(projectId: number | string, data: ProjectUpdateInput): Promise; - delete(projectId: number | string): Promise; + find(projectId: number): Promise; + update(projectId: number, data: ProjectUpdateInput): Promise; + delete(projectId: number): Promise; } export class SenderResource { create(data: SenderCreateInput): Promise; findAll(): Promise; - find(senderId: number | string): Promise; - update(senderId: number | string, data: SenderUpdateInput): Promise; - delete(senderId: number | string): Promise; + find(senderId: number): Promise; + update(senderId: number, data: SenderUpdateInput): Promise; + delete(senderId: number): Promise; } export class Pushpad { @@ -147,7 +165,7 @@ export class Pushpad { export class PushpadError extends Error { status: number; - statusText: string; + statusText?: string; body?: unknown; headers?: Record; request?: { From 29c4add38dfbce99a445028bb5a7667c9123425a Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Mon, 27 Oct 2025 12:47:42 +0100 Subject: [PATCH 07/25] Update JSDoc to use only numbers for IDs (and not strings) --- src/index.js | 2 +- src/resources/base.js | 4 ++-- src/resources/notifications.js | 8 ++++---- src/resources/projects.js | 6 +++--- src/resources/senders.js | 6 +++--- src/resources/subscriptions.js | 16 ++++++++-------- 6 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/index.js b/src/index.js index ac956fc..00a3e07 100644 --- a/src/index.js +++ b/src/index.js @@ -8,7 +8,7 @@ import { SenderResource } from './resources/senders.js'; /** * @typedef {object} PushpadOptions * @property {string} authToken - * @property {number | string} [projectId] + * @property {number} [projectId] * @property {string} [baseUrl] * @property {typeof fetch} [fetch] * @property {number} [timeout] diff --git a/src/resources/base.js b/src/resources/base.js index 5496dec..5e03eef 100644 --- a/src/resources/base.js +++ b/src/resources/base.js @@ -4,7 +4,7 @@ export class ResourceBase { /** * @param {import('../httpClient.js').HttpClient} client - * @param {number | string | undefined} defaultProjectId + * @param {number | undefined} defaultProjectId */ constructor(client, defaultProjectId) { this.client = client; @@ -13,7 +13,7 @@ export class ResourceBase { /** * Resolves the project id to be used for a request. - * @param {{ projectId?: number | string }} [options] + * @param {{ projectId?: number }} [options] * @returns {string} */ requireProjectId(options) { diff --git a/src/resources/notifications.js b/src/resources/notifications.js index 84ef435..668b7f8 100644 --- a/src/resources/notifications.js +++ b/src/resources/notifications.js @@ -7,7 +7,7 @@ export class NotificationResource extends ResourceBase { /** * Creates and sends a new notification. * @param {Record} data - * @param {{ projectId?: number | string }} [options] + * @param {{ projectId?: number }} [options] * @returns {Promise>} */ async create(data, options) { @@ -25,7 +25,7 @@ export class NotificationResource extends ResourceBase { /** * Lists notifications for a project. * @param {{ page?: number }} [query] - * @param {{ projectId?: number | string }} [options] + * @param {{ projectId?: number }} [options] * @returns {Promise} */ async findAll(query, options) { @@ -38,7 +38,7 @@ export class NotificationResource extends ResourceBase { /** * Retrieves a single notification. - * @param {number | string} notificationId + * @param {number} notificationId * @returns {Promise>} */ async find(notificationId) { @@ -50,7 +50,7 @@ export class NotificationResource extends ResourceBase { /** * Cancels a scheduled notification. - * @param {number | string} notificationId + * @param {number} notificationId * @returns {Promise} */ async cancel(notificationId) { diff --git a/src/resources/projects.js b/src/resources/projects.js index c4eac11..51681b2 100644 --- a/src/resources/projects.js +++ b/src/resources/projects.js @@ -33,7 +33,7 @@ export class ProjectResource extends ResourceBase { /** * Retrieves a project by id. - * @param {number | string} projectId + * @param {number} projectId * @returns {Promise>} */ async find(projectId) { @@ -45,7 +45,7 @@ export class ProjectResource extends ResourceBase { /** * Updates a project. - * @param {number | string} projectId + * @param {number} projectId * @param {Record} data * @returns {Promise>} */ @@ -63,7 +63,7 @@ export class ProjectResource extends ResourceBase { /** * Deletes a project. - * @param {number | string} projectId + * @param {number} projectId * @returns {Promise} */ async delete(projectId) { diff --git a/src/resources/senders.js b/src/resources/senders.js index 75aeb8b..2a74078 100644 --- a/src/resources/senders.js +++ b/src/resources/senders.js @@ -33,7 +33,7 @@ export class SenderResource extends ResourceBase { /** * Retrieves a sender by id. - * @param {number | string} senderId + * @param {number} senderId * @returns {Promise>} */ async find(senderId) { @@ -45,7 +45,7 @@ export class SenderResource extends ResourceBase { /** * Updates a sender. - * @param {number | string} senderId + * @param {number} senderId * @param {Record} data * @returns {Promise>} */ @@ -63,7 +63,7 @@ export class SenderResource extends ResourceBase { /** * Deletes a sender. - * @param {number | string} senderId + * @param {number} senderId * @returns {Promise} */ async delete(senderId) { diff --git a/src/resources/subscriptions.js b/src/resources/subscriptions.js index 7471a4a..3fbeb18 100644 --- a/src/resources/subscriptions.js +++ b/src/resources/subscriptions.js @@ -33,7 +33,7 @@ export class SubscriptionResource extends ResourceBase { /** * Creates a new subscription. * @param {Record} data - * @param {{ projectId?: number | string }} [options] + * @param {{ projectId?: number }} [options] * @returns {Promise>} */ async create(data, options) { @@ -57,7 +57,7 @@ export class SubscriptionResource extends ResourceBase { * uids?: string | string[], * tags?: string | string[] * }} [query] - * @param {{ projectId?: number | string }} [options] + * @param {{ projectId?: number }} [options] * @returns {Promise} */ async findAll(query, options) { @@ -70,8 +70,8 @@ export class SubscriptionResource extends ResourceBase { /** * Retrieves a single subscription. - * @param {number | string} subscriptionId - * @param {{ projectId?: number | string }} [options] + * @param {number} subscriptionId + * @param {{ projectId?: number }} [options] * @returns {Promise>} */ async find(subscriptionId, options) { @@ -84,9 +84,9 @@ export class SubscriptionResource extends ResourceBase { /** * Updates an existing subscription. - * @param {number | string} subscriptionId + * @param {number} subscriptionId * @param {Record} data - * @param {{ projectId?: number | string }} [options] + * @param {{ projectId?: number }} [options] * @returns {Promise>} */ async update(subscriptionId, data, options) { @@ -104,8 +104,8 @@ export class SubscriptionResource extends ResourceBase { /** * Deletes a subscription. - * @param {number | string} subscriptionId - * @param {{ projectId?: number | string }} [options] + * @param {number} subscriptionId + * @param {{ projectId?: number }} [options] * @returns {Promise} */ async delete(subscriptionId, options) { From b068cc430e3a4d92a133b5c5d9af7b6b80d89163 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Mon, 27 Oct 2025 13:01:07 +0100 Subject: [PATCH 08/25] Remove perPage query param and keep only per_page for consistency --- index.d.ts | 1 - src/resources/subscriptions.js | 3 --- test/subscriptions.test.js | 2 +- 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/index.d.ts b/index.d.ts index 2d6041f..57628a4 100644 --- a/index.d.ts +++ b/index.d.ts @@ -74,7 +74,6 @@ export interface SubscriptionUpdateInput extends Partial< export interface SubscriptionListQuery { page?: number; per_page?: number; - perPage?: number; uids?: string[]; tags?: string[]; } diff --git a/src/resources/subscriptions.js b/src/resources/subscriptions.js index 3fbeb18..dbf92a8 100644 --- a/src/resources/subscriptions.js +++ b/src/resources/subscriptions.js @@ -11,8 +11,6 @@ function normalizeQuery(query) { if (query.per_page !== undefined) { normalized.per_page = query.per_page; - } else if (query.perPage !== undefined) { - normalized.per_page = query.perPage; } if (query.uids !== undefined) { @@ -53,7 +51,6 @@ export class SubscriptionResource extends ResourceBase { * @param {{ * page?: number, * per_page?: number, - * perPage?: number, * uids?: string | string[], * tags?: string | string[] * }} [query] diff --git a/test/subscriptions.test.js b/test/subscriptions.test.js index f28e56a..c1370a6 100644 --- a/test/subscriptions.test.js +++ b/test/subscriptions.test.js @@ -49,7 +49,7 @@ test('subscription.findAll normalises query params', async () => { const fetchStub = createFetchStub([{ status: 200, body: [] }]); const client = new Pushpad({ authToken: 'token', projectId: 55, fetch: fetchStub }); - await client.subscription.findAll({ perPage: 10, uids: 'user1', tags: ['a', 'b'] }); + await client.subscription.findAll({ per_page: 10, uids: 'user1', tags: ['a', 'b'] }); const { url } = parseLastCall(fetchStub); assert.equal(url.pathname, '/api/v1/projects/55/subscriptions'); From 6a91330010db939e919faf9e7b68e2912bb0259b Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Mon, 27 Oct 2025 13:32:28 +0100 Subject: [PATCH 09/25] Add signatureFor --- index.d.ts | 1 + src/index.js | 13 +++++++++++++ test/pushpad.test.js | 10 ++++++++++ 3 files changed, 24 insertions(+) create mode 100644 test/pushpad.test.js diff --git a/index.d.ts b/index.d.ts index 57628a4..87192e2 100644 --- a/index.d.ts +++ b/index.d.ts @@ -160,6 +160,7 @@ export class Pushpad { subscription: SubscriptionResource; project: ProjectResource; sender: SenderResource; + signatureFor(data: string): string; } export class PushpadError extends Error { diff --git a/src/index.js b/src/index.js index 00a3e07..01e9d27 100644 --- a/src/index.js +++ b/src/index.js @@ -1,3 +1,4 @@ +import { createHmac } from 'node:crypto'; import HttpClient from './httpClient.js'; import { PushpadError } from './errors.js'; import { NotificationResource } from './resources/notifications.js'; @@ -32,6 +33,7 @@ export class Pushpad { throw new Error('authToken is required to initialise Pushpad.'); } + this.authToken = authToken; this.client = new HttpClient({ authToken, baseUrl, fetch, timeout }); this.notification = new NotificationResource(this.client, projectId); @@ -40,6 +42,17 @@ export class Pushpad { this.sender = new SenderResource(this.client, projectId); } + /** + * Create HMAC signature for given data + * @param {string} data + * @returns {string} + */ + signatureFor(data) { + const hmac = createHmac('sha256', this.authToken); + hmac.update(data); + return hmac.digest('hex'); + } + } export { PushpadError }; diff --git a/test/pushpad.test.js b/test/pushpad.test.js new file mode 100644 index 0000000..784d310 --- /dev/null +++ b/test/pushpad.test.js @@ -0,0 +1,10 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import Pushpad from '../src/index.js'; + +test('signatureFor creates an HMAC signature', () => { + const pushpad = new Pushpad({ authToken: '5374d7dfeffa2eb49965624ba7596a09' }); + const actual = pushpad.signatureFor('user12345'); + const expected = '6627820dab00a1971f2a6d3ff16a5ad8ba4048a02b2d402820afc61aefd0b69f'; + assert.equal(actual, expected); +}); From 10dbb0b61aa609687a4d2d913410be302f0c3138 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Mon, 27 Oct 2025 14:49:37 +0100 Subject: [PATCH 10/25] Add subscription.count --- src/httpClient.js | 20 ++++++++++++++++++-- src/resources/subscriptions.js | 28 ++++++++++++++++++++++++++++ test/httpClient.test.js | 19 +++++++++++++++++++ test/subscriptions.test.js | 14 ++++++++++++++ 4 files changed, 79 insertions(+), 2 deletions(-) diff --git a/src/httpClient.js b/src/httpClient.js index 2057235..4fa629d 100644 --- a/src/httpClient.js +++ b/src/httpClient.js @@ -56,6 +56,7 @@ function buildQueryParams(query) { * @property {Record} [headers] * @property {number | number[]} [expectedStatuses] * @property {boolean} [expectBody] + * @property {boolean} [includeHeaders] */ /** @@ -104,7 +105,8 @@ export class HttpClient { body, headers = {}, expectedStatuses, - expectBody + expectBody, + includeHeaders } = options; const base = this.baseUrl.endsWith('/') ? this.baseUrl : `${this.baseUrl}/`; @@ -168,6 +170,12 @@ export class HttpClient { const successfulStatusCodes = expected ?? [200, 201, 202, 204]; const hasSuccessfulStatus = successfulStatusCodes.includes(response.status); + const headerEntries = []; + for (const [key, value] of response.headers.entries()) { + headerEntries.push([key.toLowerCase(), value]); + } + const responseHeaders = Object.fromEntries(headerEntries); + const contentType = response.headers.get('content-type') ?? ''; const shouldParseBody = expectBody ?? (response.status !== 204 && contentType.includes('application/json')); @@ -197,11 +205,19 @@ export class HttpClient { status: response.status, statusText: response.statusText, body: parsedBody, - headers: Object.fromEntries(response.headers.entries()), + headers: responseHeaders, request: { method, url: url.toString(), body } }); } + if (includeHeaders) { + return { + body: parsedBody, + headers: responseHeaders, + status: response.status + }; + } + return parsedBody; } } diff --git a/src/resources/subscriptions.js b/src/resources/subscriptions.js index dbf92a8..ae7d9db 100644 --- a/src/resources/subscriptions.js +++ b/src/resources/subscriptions.js @@ -65,6 +65,34 @@ export class SubscriptionResource extends ResourceBase { }); } + /** + * Counts subscriptions for a project, with optional filters. + * @param {{ + * uids?: string | string[], + * tags?: string | string[] + * }} [query] + * @param {{ projectId?: number }} [options] + * @returns {Promise} + */ + async count(query, options) { + const projectId = this.requireProjectId(options); + + const response = await this.client.request('HEAD', `projects/${projectId}/subscriptions`, { + query: normalizeQuery(query), + expectedStatuses: 200, + expectBody: false, + includeHeaders: true + }); + + const total = Number(response.headers['x-total-count']); + + if (!Number.isInteger(total)) { + throw new Error('Invalid or missing x-total-count header in Pushpad API response.'); + } + + return total; + } + /** * Retrieves a single subscription. * @param {number} subscriptionId diff --git a/test/httpClient.test.js b/test/httpClient.test.js index b363b2f..fd77038 100644 --- a/test/httpClient.test.js +++ b/test/httpClient.test.js @@ -91,3 +91,22 @@ test('HttpClient propagates timeout errors as PushpadError', async () => { assert(abortSignal.aborted); }); + +test('HttpClient includeHeaders returns lowercase header keys', async () => { + const fetchStub = createFetchStub([ + { status: 200, headers: { 'X-Custom-Header': 'Value', 'Another': '123' } } + ]); + const client = new HttpClient({ authToken: 'token', fetch: fetchStub }); + + const response = await client.request('HEAD', 'resources', { + includeHeaders: true, + expectBody: false + }); + + assert.equal(response.status, 200); + assert.deepEqual(response.headers, { + 'x-custom-header': 'Value', + another: '123', + 'content-type': 'application/json' + }); +}); diff --git a/test/subscriptions.test.js b/test/subscriptions.test.js index c1370a6..a09286c 100644 --- a/test/subscriptions.test.js +++ b/test/subscriptions.test.js @@ -58,6 +58,20 @@ test('subscription.findAll normalises query params', async () => { assert.deepEqual(url.searchParams.getAll('tags[]'), ['a', 'b']); }); +test('subscription.count returns total using HEAD request', async () => { + const fetchStub = createFetchStub([{ status: 200, headers: { 'X-Total-Count': '123' } }]); + const client = new Pushpad({ authToken: 'token', projectId: 77, fetch: fetchStub }); + + const total = await client.subscription.count({ uids: 'user1', tags: ['vip'] }); + + assert.equal(total, 123); + const { call, url } = parseLastCall(fetchStub); + assert.equal(call.options.method, 'HEAD'); + assert.equal(url.pathname, '/api/v1/projects/77/subscriptions'); + assert.deepEqual(url.searchParams.getAll('uids[]'), ['user1']); + assert.deepEqual(url.searchParams.getAll('tags[]'), ['vip']); +}); + test('subscription.find retrieves a subscription', async () => { const subscriptionResponse = { id: 999, uid: 'user1', tags: ['a'] }; const fetchStub = createFetchStub([{ status: 200, body: subscriptionResponse }]); From 41d86fd3dec552ba864f220b6b735e3bb0ab9f62 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Tue, 28 Oct 2025 10:34:35 +0100 Subject: [PATCH 11/25] Add subscription.count to typescript definitions --- index.d.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/index.d.ts b/index.d.ts index 87192e2..ff061a2 100644 --- a/index.d.ts +++ b/index.d.ts @@ -133,6 +133,7 @@ export class NotificationResource { export class SubscriptionResource { create(data: SubscriptionCreateInput, options?: RequestOptions): Promise; findAll(query?: SubscriptionListQuery, options?: RequestOptions): Promise; + count(query?: Pick, options?: RequestOptions): Promise; find(subscriptionId: number, options?: RequestOptions): Promise; update(subscriptionId: number, data: SubscriptionUpdateInput, options?: RequestOptions): Promise; delete(subscriptionId: number, options?: RequestOptions): Promise; From c953a4040561d729a0ef4268b7937226dd2bbf0f Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Tue, 28 Oct 2025 11:42:58 +0100 Subject: [PATCH 12/25] Add README for new version --- README.md | 328 ++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 281 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index fa2762d..cd54370 100755 --- a/README.md +++ b/README.md @@ -1,35 +1,56 @@ # Pushpad - Web Push Notifications +[![npm version](https://img.shields.io/npm/v/pushpad.svg)](https://www.npmjs.com/package/pushpad) ![Build Status](https://github.com/pushpad/pushpad-node/workflows/CI/badge.svg) - + [Pushpad](https://pushpad.xyz) is a service for sending push notifications from websites and web apps. It uses the **Push API**, which is a standard supported by all major browsers (Chrome, Firefox, Opera, Edge, Safari). The notifications are delivered in real time even when the users are not on your website and you can target specific users or send bulk notifications. ## Installation -Using NPM: + +Add this package to your project dependencies: + ```bash npm install pushpad ``` +Or add it with Yarn: + +```bash +yarn add pushpad +``` + ## Getting started First you need to sign up to Pushpad and create a project there. -Then set your authentication credentials and project: +Then set your authentication credentials: ```javascript -var pushpad = require('pushpad'); +import Pushpad from 'pushpad'; -var project = new pushpad.Pushpad({ - authToken: AUTH_TOKEN, - projectId: PROJECT_ID +const pushpad = new Pushpad({ + authToken: '5374d7dfeffa2eb49965624ba7596a09', + projectId: 123 // set it here or pass it as a param to methods later }); ``` -- `authToken` can be found in the user account settings. +- `authToken` can be found in the user account settings. - `projectId` can be found in the project settings. +If your application uses multiple projects, you can pass the `projectId` as an option to methods: + +```javascript +pushpad.notification.create(data, { projectId: 123 }); + +pushpad.notification.findAll(query, { projectId: 123 }); + +pushpad.subscription.count(query, { projectId: 123 }); + +// ... +``` + ## Collecting user subscriptions to push notifications You can subscribe the users to your notifications using the Javascript SDK, as described in the [getting started guide](https://pushpad.xyz/docs/pushpad_pro_getting_started). @@ -37,25 +58,13 @@ You can subscribe the users to your notifications using the Javascript SDK, as d If you need to generate the HMAC signature for the `uid` you can use this helper: ```javascript -project.signatureFor(currentUserId) +pushpad.signatureFor(currentUserId); ``` ## Sending push notifications ```javascript -var pushpad = require('./index'); - -var AUTH_TOKEN = 'e991832a51afc9da49baf29e7f9de6a6'; -var PROJECT_ID = 763; - -var project = new pushpad.Pushpad({ - authToken: AUTH_TOKEN, - projectId: PROJECT_ID -}); - -var notification = new pushpad.Notification({ - project: project, - +const payload = { // required, the main content of the notification body: 'Hello world!', @@ -63,23 +72,23 @@ var notification = new pushpad.Notification({ title: 'Website Name', // optional, open this link on notification click (defaults to your project website) - targetUrl: 'https://example.com', + target_url: 'https://example.com', // optional, the icon of the notification (defaults to the project icon) - iconUrl: 'https://example.com/assets/icon.png', + icon_url: 'https://example.com/assets/icon.png', // optional, the small icon displayed in the status bar (defaults to the project badge) - badgeUrl: 'https://example.com/assets/badge.png', + badge_url: 'https://example.com/assets/badge.png', // optional, an image to display in the notification content // see https://pushpad.xyz/docs/sending_images - imageUrl: 'https://example.com/assets/image.png', + image_url: 'https://example.com/assets/image.png', // optional, drop the notification after this number of seconds if a device is offline ttl: 604800, // optional, prevent Chrome on desktop from automatically closing the notification after a few seconds - requireInteraction: true, + require_interaction: true, // optional, enable this option if you want a mute notification without any sound silent: false, @@ -88,14 +97,14 @@ var notification = new pushpad.Notification({ urgent: false, // optional, a string that is passed as an argument to action button callbacks - customData: '123', + custom_data: '123', // optional, add some action buttons to the notification // see https://pushpad.xyz/docs/action_buttons actions: [ { title: 'My Button 1', - targetUrl: 'https://example.com/button-link', // optional + target_url: 'https://example.com/button-link', // optional icon: 'https://example.com/assets/button-icon.png', // optional action: 'myActionName' // optional } @@ -106,51 +115,276 @@ var notification = new pushpad.Notification({ // optional, use this option only if you need to create scheduled notifications (max 5 days) // see https://pushpad.xyz/docs/schedule_notifications - sendAt: new Date(Date.UTC(2016, 7 - 1, 25, 10, 9)), // 2016-07-25 10:09 UTC + send_at: '2016-07-25T10:09:00Z', // optional, add the notification to custom categories for stats aggregation // see https://pushpad.xyz/docs/monitoring - customMetrics: ['examples', 'another_metric'] // up to 3 metrics per notification -}); + custom_metrics: ['examples', 'another_metric'] // up to 3 metrics per notification +}; // deliver to a user -notification.deliverTo(user1, function(err, result) { /*...*/ }); +await pushpad.notification.create({ ...payload, uids: ['user-1'] }); // deliver to a group of users -notification.deliverTo([user1, user2, user3], function(err, result) { /*...*/ }); +await pushpad.notification.create({ ...payload, uids: ['user-1', 'user-2', 'user-3'] }); // deliver to some users only if they have a given preference // e.g. only "users" who have a interested in "events" will be reached -notification.deliverTo(users, { tags: ['events'] }, function (err, result) { /*...*/ }); +await pushpad.notification.create({ ...payload, uids: ['user-1', 'user-2'], tags: ['events'] }); // deliver to segments // e.g. any subscriber that has the tag "segment1" OR "segment2" -notification.broadcast({ tags: ['segment1', 'segment2'] }, function (err, result) { /*...*/ }); +await pushpad.notification.create({ ...payload, tags: ['segment1', 'segment2'] }); // you can use boolean expressions // they can include parentheses and the operators !, &&, || (from highest to lowest precedence) // https://pushpad.xyz/docs/tags -var filter1 = ['zip_code:28865 && !optout:local_events || friend_of:Organizer123']; -notification.broadcast({ tags: filter1 }, function (err, result) { /*...*/ }); -var filter2 = ['tag1 && tag2', 'tag3']; // equal to 'tag1 && tag2 || tag3' -notification.deliverTo(users, { tags: filter2 }, function (err, result) { /*...*/ }); +await pushpad.notification.create({ ...payload, tags: ['zip_code:28865 && !optout:local_events || friend_of:Organizer123'] }); +await pushpad.notification.create({ ...payload, tags: ['tag1 && tag2', 'tag3'] }); // equal to 'tag1 && tag2 || tag3' // deliver to everyone -notification.broadcast(function(err, result) { /*...*/ }); +await pushpad.notification.create(payload); ``` You can set the default values for most fields in the project settings. See also [the docs](https://pushpad.xyz/docs/rest_api#notifications_api_docs) for more information about notification fields. If you try to send a notification to a user ID, but that user is not subscribed, that ID is simply ignored. -The methods above return an object: +These fields are also returned by the API: + +```javascript +const result = await pushpad.notification.create(payload); + +// Notification ID +console.log(result.id); // => 1000 + +// Estimated number of devices that will receive the notification +// Not available for notifications that use send_at +console.log(result.scheduled); // => 5 + +// Available only if you specify some user IDs (uids) in the request: +// it indicates which of those users are subscribed to notifications. +// Not available for notifications that use send_at +console.log(result.uids); // => ["user1", "user2"] + +// The time when the notification will be sent. +// Available for notifications that use send_at +console.log(result.send_at); // => "2025-10-30T10:09:00.000Z" +``` + +## Getting push notification data + +You can retrieve data for past notifications: + +```javascript +const notification = await pushpad.notification.find(42); + +// get basic attributes +notification.id; // => 42 +notification.title; // => 'Foo Bar' +notification.body; // => 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.' +notification.target_url; // => 'https://example.com' +notification.ttl; // => 604800 +notification.require_interaction; // => false +notification.silent; // => false +notification.urgent; // => false +notification.icon_url; // => 'https://example.com/assets/icon.png' +notification.badge_url; // => 'https://example.com/assets/badge.png' +notification.created_at; // => '2025-07-06T10:09:14.000Z' + +// get statistics +notification.scheduled_count; // => 1 +notification.successfully_sent_count; // => 4 +notification.opened_count; // => 2 +``` + +Or for multiple notifications of a project at once: + +```javascript +const notifications = await pushpad.notification.findAll({ page: 1 }); + +// same attributes as for single notification in example above +notifications[0].id; // => 42 +notifications[0].title; // => 'Foo Bar' +``` + +The REST API paginates the result set. You can pass a `page` parameter to get the full list in multiple requests. + +```javascript +const notifications = await pushpad.notification.findAll({ page: 2 }); +``` + +## Scheduled notifications + +You can create scheduled notifications that will be sent in the future: + +```javascript +await pushpad.notification.create({ + body: 'This notification will be sent after 60 seconds', + send_at: new Date(Date.now() + 60_000).toISOString() +}); +``` + +You can also cancel a scheduled notification: + +```javascript +await pushpad.notification.cancel(id); +``` + +## Getting subscription count + +You can retrieve the number of subscriptions for a given project, optionally filtered by `tags` or `uids`: + +```javascript +await pushpad.subscription.count(); // => 100 +await pushpad.subscription.count({ uids: ['user1'] }); // => 2 +await pushpad.subscription.count({ tags: ['sports'] }); // => 10 +await pushpad.subscription.count({ tags: ['sports && travel'] }); // => 5 +await pushpad.subscription.count({ uids: ['user1'], tags: ['sports && travel'] }); // => 1 +``` -- `id` is the id of the notification on Pushpad -- `scheduled` is the estimated reach of the notification (i.e. the number of devices to which the notification will be sent, which can be different from the number of users, since a user may receive notifications on multiple devices) -- `uids` (`deliverTo` only) are the user IDs that will be actually reached by the notification because they are subscribed to your notifications. For example if you send a notification to `['uid1', 'uid2', 'uid3']`, but only `'uid1'` is subscribed, you will get `['uid1']` in response. Note that if a user has unsubscribed after the last notification sent to him, he may still be reported for one time as subscribed (this is due to the way the W3C Push API works). -- `send_at` is present only for scheduled notifications. The fields `scheduled` and `uids` are not available in this case. +## Getting push subscription data +You can retrieve the subscriptions for a given project, optionally filtered by `tags` or `uids`: + +```javascript +await pushpad.subscription.findAll(); +await pushpad.subscription.findAll({ uids: ['user1'] }); +await pushpad.subscription.findAll({ tags: ['sports'] }); +await pushpad.subscription.findAll({ tags: ['sports && travel'] }); +await pushpad.subscription.findAll({ uids: ['user1'], tags: ['sports && travel'] }); +``` + +The REST API paginates the result set. You can pass a `page` parameter to get the full list in multiple requests. + +```javascript +const subscriptions = await pushpad.subscription.findAll({ page: 2 }); +``` + +You can also retrieve the data of a specific subscription if you already know its id: + +```javascript +await pushpad.subscription.find(123); +``` + +## Updating push subscription data + +Usually you add data, like user IDs and tags, to the push subscriptions using the [JavaScript SDK](https://pushpad.xyz/docs/javascript_sdk_reference) in the frontend. + +However you can also update the subscription data from your server: + +```javascript +const subscriptions = await pushpad.subscription.findAll({ uids: ['user1'] }); + +for (const subscription of subscriptions) { + // update the user ID associated to the push subscription + await pushpad.subscription.update(subscription.id, { uid: 'myuser1' }); + + // update the tags associated to the push subscription + const tags = [...(subscription.tags ?? [])]; + tags.push('another_tag'); + await pushpad.subscription.update(subscription.id, { tags }); +} +``` + +## Importing push subscriptions + +If you need to [import](https://pushpad.xyz/docs/import) some existing push subscriptions (from another service to Pushpad, or from your backups) or if you simply need to create some test data, you can use this method: + +```javascript +const attributes = { + endpoint: 'https://example.com/push/f7Q1Eyf7EyfAb1', + p256dh: 'BCQVDTlYWdl05lal3lG5SKr3VxTrEWpZErbkxWrzknHrIKFwihDoZpc_2sH6Sh08h-CacUYI-H8gW4jH-uMYZQ4=', + auth: 'cdKMlhgVeSPzCXZ3V7FtgQ==', + uid: 'exampleUid', + tags: ['exampleTag1', 'exampleTag2'] +}; + +const subscription = await pushpad.subscription.create(attributes); +``` + +Please note that this is not the standard way to collect subscriptions on Pushpad: usually you subscribe the users to the notifications using the [JavaScript SDK](https://pushpad.xyz/docs/javascript_sdk_reference) in the frontend. + +## Deleting push subscriptions + +Usually you unsubscribe a user from push notifications using the [JavaScript SDK](https://pushpad.xyz/docs/javascript_sdk_reference) in the frontend (recommended). + +However you can also delete the subscriptions using this library. Be careful, the subscriptions are permanently deleted! + +```javascript +await pushpad.subscription.delete(id); +``` + +## Managing projects + +Projects are usually created manually from the Pushpad dashboard. However you can also create projects from code if you need advanced automation or if you manage [many different domains](https://pushpad.xyz/docs/multiple_domains). + +```javascript +const attributes = { + // required attributes + sender_id: 123, + name: 'My project', + website: 'https://example.com', + + // optional configurations + icon_url: 'https://example.com/icon.png', + badge_url: 'https://example.com/badge.png', + notifications_ttl: 604800, + notifications_require_interaction: false, + notifications_silent: false +}; + +const project = await pushpad.project.create(attributes); +``` + +You can also find, update and delete projects: + +```javascript +const projects = await pushpad.project.findAll(); +projects.forEach((p) => { + console.log(`Project ${p.id}: ${p.name}`); +}); + +const existingProject = await pushpad.project.find(123); + +await pushpad.project.update(existingProject.id, { name: 'The New Project Name' }); + +await pushpad.project.delete(existingProject.id); +``` + +## Managing senders + +Senders are usually created manually from the Pushpad dashboard. However you can also create senders from code. + +```javascript +const attributes = { + // required attributes + name: 'My sender', + + // optional configurations + // do not include these fields if you want to generate them automatically + vapid_private_key: '-----BEGIN EC PRIVATE KEY----- ...', + vapid_public_key: '-----BEGIN PUBLIC KEY----- ...' +}; + +const sender = await pushpad.sender.create(attributes); +``` + +You can also find, update and delete senders: + +```javascript +const senders = await pushpad.sender.findAll(); +senders.forEach((s) => { + console.log(`Sender ${s.id}: ${s.name}`); +}); + +const existingSender = await pushpad.sender.find(987); + +await pushpad.sender.update(existingSender.id, { name: 'The New Sender Name' }); + +await pushpad.sender.delete(existingSender.id); +``` ## License -The library is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). +The package is available as open source under the terms of the [MIT License](https://opensource.org/license/MIT). From d4d68c6d828c657450af6a5b453c204d774a66c2 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Tue, 28 Oct 2025 13:03:42 +0100 Subject: [PATCH 13/25] Add some sections to README --- README.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/README.md b/README.md index cd54370..f82a6c5 100755 --- a/README.md +++ b/README.md @@ -385,6 +385,38 @@ await pushpad.sender.update(existingSender.id, { name: 'The New Sender Name' }); await pushpad.sender.delete(existingSender.id); ``` +## Error handling + +All API requests return promises. Failed requests throw a `PushpadError` that exposes the HTTP status, response body, headers, and request metadata: + +```javascript +try { + await pushpad.notification.create({ body: 'Hello' }); +} catch (error) { + if (error instanceof PushpadError) { + console.error(error.status, error.body); + } +} +``` + +## TypeScript support + +Type definitions ship with the package (`index.d.ts`) and cover all resources, input types, and response shapes. Importing from TypeScript works out of the box: + +```typescript +import Pushpad from 'pushpad'; + +// types are inferred automatically from type definitions in the package +const client = new Pushpad({ authToken: 'token', projectId: 123 }); +const result = await client.notification.create({ body: 'Hello' }); +``` + +## Documentation + +- Pushpad REST API reference: https://pushpad.xyz/docs/rest_api +- Getting started guide (for collecting subscriptions): https://pushpad.xyz/docs/pushpad_pro_getting_started +- JavaScript SDK reference (frontend): https://pushpad.xyz/docs/javascript_sdk_reference + ## License The package is available as open source under the terms of the [MIT License](https://opensource.org/license/MIT). From 8ab72b651fe5953b2b451a6f96c806677f2d50cb Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Tue, 28 Oct 2025 13:23:58 +0100 Subject: [PATCH 14/25] Add quickstart section to README --- README.md | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/README.md b/README.md index f82a6c5..7b4efe8 100755 --- a/README.md +++ b/README.md @@ -21,6 +21,61 @@ Or add it with Yarn: yarn add pushpad ``` +## TL;DR Quickstart + +```javascript +import Pushpad from "pushpad"; + +const pushpad = new Pushpad({ + authToken: "token", // from account settings + projectId: 123 // from project settings +}); + +try { + // send a notification + const result = await pushpad.notification.create({ + body : "Your message" + // and all the other fields + }); + console.log(result.id); +} catch (err) { + console.log(err); +} + +// you can also pass projectId directly to a function (instead of setting it globally) +const result = await pushpad.notification.create({ + body : "Your message" +}, { projectId: 123 }); + +// Notifications API +pushpad.notification.create(data, options); +pushpad.notification.findAll(query, options); +pushpad.notification.find(id); +pushpad.notification.cancel(id); + +// Subscriptions API +pushpad.subscription.create(data, options); +pushpad.subscription.count(query, options); +pushpad.subscription.findAll(query, options); +pushpad.subscription.find(id, options); +pushpad.subscription.update(id, data, options); +pushpad.subscription.delete(id, options); + +// Projects API +pushpad.project.create(data); +pushpad.project.findAll(); +pushpad.project.find(id); +pushpad.project.update(id, data); +pushpad.project.delete(id); + +// Senders API +pushpad.sender.create(data); +pushpad.sender.findAll(); +pushpad.sender.find(id); +pushpad.sender.update(id, data); +pushpad.sender.delete(id); +``` + ## Getting started First you need to sign up to Pushpad and create a project there. From fc21c93bc88fdb88a8fb068e89bd75557bb5eb32 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Tue, 28 Oct 2025 13:34:20 +0100 Subject: [PATCH 15/25] Improve some code in README --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 7b4efe8..3bda386 100755 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ const pushpad = new Pushpad({ try { // send a notification const result = await pushpad.notification.create({ - body : "Your message" + body: "Your message", // and all the other fields }); console.log(result.id); @@ -44,7 +44,7 @@ try { // you can also pass projectId directly to a function (instead of setting it globally) const result = await pushpad.notification.create({ - body : "Your message" + body: "Your message" }, { projectId: 123 }); // Notifications API @@ -445,6 +445,8 @@ await pushpad.sender.delete(existingSender.id); All API requests return promises. Failed requests throw a `PushpadError` that exposes the HTTP status, response body, headers, and request metadata: ```javascript +import { PushpadError } from 'pushpad'; + try { await pushpad.notification.create({ body: 'Hello' }); } catch (error) { From 048e149b416465141741f817754b69713da84150 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Tue, 28 Oct 2025 15:25:39 +0100 Subject: [PATCH 16/25] Add types to exports in package.json --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index b6d0c81..9823988 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "main": "src/index.js", "exports": { ".": { - "import": "./src/index.js" + "import": "./src/index.js", + "types": "./index.d.ts" } }, "types": "index.d.ts", From 5b9c985020fa2406f624f2c0921ec29c66d1e2ef Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Tue, 28 Oct 2025 18:04:32 +0100 Subject: [PATCH 17/25] Refactor index.d.ts: split it in multiple files (in the types directory) --- index.d.ts | 179 -------------------------------- package.json | 4 +- types/Notification.d.ts | 41 ++++++++ types/NotificationResource.d.ts | 20 ++++ types/Options.d.ts | 11 ++ types/Project.d.ts | 12 +++ types/ProjectResource.d.ts | 14 +++ types/Pushpad.d.ts | 17 +++ types/PushpadError.d.ts | 11 ++ types/Sender.d.ts | 7 ++ types/SenderResource.d.ts | 16 +++ types/Subscription.d.ts | 11 ++ types/SubscriptionResource.d.ts | 29 ++++++ types/index.d.ts | 12 +++ 14 files changed, 203 insertions(+), 181 deletions(-) delete mode 100644 index.d.ts create mode 100644 types/Notification.d.ts create mode 100644 types/NotificationResource.d.ts create mode 100644 types/Options.d.ts create mode 100644 types/Project.d.ts create mode 100644 types/ProjectResource.d.ts create mode 100644 types/Pushpad.d.ts create mode 100644 types/PushpadError.d.ts create mode 100644 types/Sender.d.ts create mode 100644 types/SenderResource.d.ts create mode 100644 types/Subscription.d.ts create mode 100644 types/SubscriptionResource.d.ts create mode 100644 types/index.d.ts diff --git a/index.d.ts b/index.d.ts deleted file mode 100644 index ff061a2..0000000 --- a/index.d.ts +++ /dev/null @@ -1,179 +0,0 @@ -export interface NotificationAction { - title?: string; - target_url?: string; - icon?: string; - action?: string; -} - -export interface Notification { - id?: number; - project_id?: number; - title?: string; - body?: string; - target_url?: string; - icon_url?: string; - badge_url?: string; - image_url?: string; - ttl?: number; - require_interaction?: boolean; - silent?: boolean; - urgent?: boolean; - custom_data?: string; - actions?: NotificationAction[]; - starred?: boolean; - send_at?: string; - custom_metrics?: string[]; - uids?: string[]; - tags?: string[]; - created_at?: string; - successfully_sent_count?: number; - opened_count?: number; - scheduled_count?: number; - scheduled?: boolean; - cancelled?: boolean; -} - -export interface NotificationCreateInput extends Omit< - Notification, - 'id' | 'project_id' | 'created_at' | 'successfully_sent_count' | 'opened_count' | 'scheduled_count' | 'scheduled' | 'cancelled' -> { - body: string; -} - -export interface NotificationCreateResult { - id: number; - scheduled?: number; - uids?: string[]; - send_at?: string; -} - -export interface NotificationListQuery { - page?: number; -} - -export interface Subscription { - id?: number; - project_id?: number; - endpoint?: string; - p256dh?: string; - auth?: string; - uid?: string; - tags?: string[]; - last_click_at?: string; - created_at?: string; -} - -export interface SubscriptionCreateInput extends Omit { - endpoint: string; -} - -export interface SubscriptionUpdateInput extends Partial< - Omit -> {} - -export interface SubscriptionListQuery { - page?: number; - per_page?: number; - uids?: string[]; - tags?: string[]; -} - -export interface Project { - id?: number; - sender_id?: number; - name?: string; - website?: string; - icon_url?: string; - badge_url?: string; - notifications_ttl?: number; - notifications_require_interaction?: boolean; - notifications_silent?: boolean; - created_at?: string; -} - -export interface ProjectCreateInput extends Required>, - Omit {} - -export interface ProjectUpdateInput extends Partial> {} - -export interface Sender { - id?: number; - name?: string; - vapid_private_key?: string; - vapid_public_key?: string; - created_at?: string; -} - -export interface SenderCreateInput extends Required>, - Omit {} - -export interface SenderUpdateInput extends Partial< - Omit -> {} - -export interface RequestOptions { - projectId?: number; -} - -export interface PushpadOptions { - authToken: string; - projectId?: number; - baseUrl?: string; - fetch?: typeof fetch; - timeout?: number; -} - -export class NotificationResource { - create(data: NotificationCreateInput, options?: RequestOptions): Promise; - findAll(query?: NotificationListQuery, options?: RequestOptions): Promise; - find(notificationId: number): Promise; - cancel(notificationId: number): Promise; -} - -export class SubscriptionResource { - create(data: SubscriptionCreateInput, options?: RequestOptions): Promise; - findAll(query?: SubscriptionListQuery, options?: RequestOptions): Promise; - count(query?: Pick, options?: RequestOptions): Promise; - find(subscriptionId: number, options?: RequestOptions): Promise; - update(subscriptionId: number, data: SubscriptionUpdateInput, options?: RequestOptions): Promise; - delete(subscriptionId: number, options?: RequestOptions): Promise; -} - -export class ProjectResource { - create(data: ProjectCreateInput): Promise; - findAll(): Promise; - find(projectId: number): Promise; - update(projectId: number, data: ProjectUpdateInput): Promise; - delete(projectId: number): Promise; -} - -export class SenderResource { - create(data: SenderCreateInput): Promise; - findAll(): Promise; - find(senderId: number): Promise; - update(senderId: number, data: SenderUpdateInput): Promise; - delete(senderId: number): Promise; -} - -export class Pushpad { - constructor(options: PushpadOptions); - notification: NotificationResource; - subscription: SubscriptionResource; - project: ProjectResource; - sender: SenderResource; - signatureFor(data: string): string; -} - -export class PushpadError extends Error { - status: number; - statusText?: string; - body?: unknown; - headers?: Record; - request?: { - method: string; - url: string; - body?: unknown; - }; -} - -export default Pushpad; diff --git a/package.json b/package.json index 9823988..e10197b 100644 --- a/package.json +++ b/package.json @@ -7,10 +7,10 @@ "exports": { ".": { "import": "./src/index.js", - "types": "./index.d.ts" + "types": "./types/index.d.ts" } }, - "types": "index.d.ts", + "types": "types/index.d.ts", "keywords": [ "pushpad", "web-push", diff --git a/types/Notification.d.ts b/types/Notification.d.ts new file mode 100644 index 0000000..a06fbdf --- /dev/null +++ b/types/Notification.d.ts @@ -0,0 +1,41 @@ +export interface NotificationAction { + title?: string; + target_url?: string; + icon?: string; + action?: string; +} + +export interface Notification { + id?: number; + project_id?: number; + title?: string; + body?: string; + target_url?: string; + icon_url?: string; + badge_url?: string; + image_url?: string; + ttl?: number; + require_interaction?: boolean; + silent?: boolean; + urgent?: boolean; + custom_data?: string; + actions?: NotificationAction[]; + starred?: boolean; + send_at?: string; + custom_metrics?: string[]; + uids?: string[]; + tags?: string[]; + created_at?: string; + successfully_sent_count?: number; + opened_count?: number; + scheduled_count?: number; + scheduled?: boolean; + cancelled?: boolean; +} + +export interface NotificationCreateResult { + id: number; + scheduled?: number; + uids?: string[]; + send_at?: string; +} diff --git a/types/NotificationResource.d.ts b/types/NotificationResource.d.ts new file mode 100644 index 0000000..402515e --- /dev/null +++ b/types/NotificationResource.d.ts @@ -0,0 +1,20 @@ +import type { Notification, NotificationCreateResult } from './Notification'; +import type { RequestOptions } from './Options'; + +export interface NotificationCreateInput extends Omit< + Notification, + 'id' | 'project_id' | 'created_at' | 'successfully_sent_count' | 'opened_count' | 'scheduled_count' | 'scheduled' | 'cancelled' +> { + body: string; +} + +export interface NotificationListQuery { + page?: number; +} + +export class NotificationResource { + create(data: NotificationCreateInput, options?: RequestOptions): Promise; + findAll(query?: NotificationListQuery, options?: RequestOptions): Promise; + find(notificationId: number): Promise; + cancel(notificationId: number): Promise; +} diff --git a/types/Options.d.ts b/types/Options.d.ts new file mode 100644 index 0000000..6593245 --- /dev/null +++ b/types/Options.d.ts @@ -0,0 +1,11 @@ +export interface RequestOptions { + projectId?: number; +} + +export interface PushpadOptions { + authToken: string; + projectId?: number; + baseUrl?: string; + fetch?: typeof fetch; + timeout?: number; +} diff --git a/types/Project.d.ts b/types/Project.d.ts new file mode 100644 index 0000000..3a1d6cb --- /dev/null +++ b/types/Project.d.ts @@ -0,0 +1,12 @@ +export interface Project { + id?: number; + sender_id?: number; + name?: string; + website?: string; + icon_url?: string; + badge_url?: string; + notifications_ttl?: number; + notifications_require_interaction?: boolean; + notifications_silent?: boolean; + created_at?: string; +} diff --git a/types/ProjectResource.d.ts b/types/ProjectResource.d.ts new file mode 100644 index 0000000..e78334b --- /dev/null +++ b/types/ProjectResource.d.ts @@ -0,0 +1,14 @@ +import type { Project } from './Project'; + +export interface ProjectCreateInput extends Required>, + Omit {} + +export interface ProjectUpdateInput extends Partial> {} + +export class ProjectResource { + create(data: ProjectCreateInput): Promise; + findAll(): Promise; + find(projectId: number): Promise; + update(projectId: number, data: ProjectUpdateInput): Promise; + delete(projectId: number): Promise; +} diff --git a/types/Pushpad.d.ts b/types/Pushpad.d.ts new file mode 100644 index 0000000..795a2db --- /dev/null +++ b/types/Pushpad.d.ts @@ -0,0 +1,17 @@ +import type { PushpadOptions } from './Options'; +import type { NotificationResource } from './NotificationResource'; +import type { SubscriptionResource } from './SubscriptionResource'; +import type { ProjectResource } from './ProjectResource'; +import type { SenderResource } from './SenderResource'; + +declare class Pushpad { + constructor(options: PushpadOptions); + notification: NotificationResource; + subscription: SubscriptionResource; + project: ProjectResource; + sender: SenderResource; + signatureFor(data: string): string; +} + +export default Pushpad; +export { Pushpad }; diff --git a/types/PushpadError.d.ts b/types/PushpadError.d.ts new file mode 100644 index 0000000..5d70524 --- /dev/null +++ b/types/PushpadError.d.ts @@ -0,0 +1,11 @@ +export class PushpadError extends Error { + status: number; + statusText?: string; + body?: unknown; + headers?: Record; + request?: { + method: string; + url: string; + body?: unknown; + }; +} diff --git a/types/Sender.d.ts b/types/Sender.d.ts new file mode 100644 index 0000000..4fcf913 --- /dev/null +++ b/types/Sender.d.ts @@ -0,0 +1,7 @@ +export interface Sender { + id?: number; + name?: string; + vapid_private_key?: string; + vapid_public_key?: string; + created_at?: string; +} diff --git a/types/SenderResource.d.ts b/types/SenderResource.d.ts new file mode 100644 index 0000000..2f74f14 --- /dev/null +++ b/types/SenderResource.d.ts @@ -0,0 +1,16 @@ +import type { Sender } from './Sender'; + +export interface SenderCreateInput extends Required>, + Omit {} + +export interface SenderUpdateInput extends Partial< + Omit +> {} + +export class SenderResource { + create(data: SenderCreateInput): Promise; + findAll(): Promise; + find(senderId: number): Promise; + update(senderId: number, data: SenderUpdateInput): Promise; + delete(senderId: number): Promise; +} diff --git a/types/Subscription.d.ts b/types/Subscription.d.ts new file mode 100644 index 0000000..6e2a517 --- /dev/null +++ b/types/Subscription.d.ts @@ -0,0 +1,11 @@ +export interface Subscription { + id?: number; + project_id?: number; + endpoint?: string; + p256dh?: string; + auth?: string; + uid?: string; + tags?: string[]; + last_click_at?: string; + created_at?: string; +} diff --git a/types/SubscriptionResource.d.ts b/types/SubscriptionResource.d.ts new file mode 100644 index 0000000..358065d --- /dev/null +++ b/types/SubscriptionResource.d.ts @@ -0,0 +1,29 @@ +import type { Subscription } from './Subscription'; +import type { RequestOptions } from './Options'; + +export interface SubscriptionCreateInput extends Omit< + Subscription, + 'id' | 'project_id' | 'last_click_at' | 'created_at' +> { + endpoint: string; +} + +export interface SubscriptionUpdateInput extends Partial< + Omit +> {} + +export interface SubscriptionListQuery { + page?: number; + per_page?: number; + uids?: string[]; + tags?: string[]; +} + +export class SubscriptionResource { + create(data: SubscriptionCreateInput, options?: RequestOptions): Promise; + findAll(query?: SubscriptionListQuery, options?: RequestOptions): Promise; + count(query?: Pick, options?: RequestOptions): Promise; + find(subscriptionId: number, options?: RequestOptions): Promise; + update(subscriptionId: number, data: SubscriptionUpdateInput, options?: RequestOptions): Promise; + delete(subscriptionId: number, options?: RequestOptions): Promise; +} diff --git a/types/index.d.ts b/types/index.d.ts new file mode 100644 index 0000000..0238971 --- /dev/null +++ b/types/index.d.ts @@ -0,0 +1,12 @@ +export * from './Notification'; +export * from './NotificationResource'; +export * from './Subscription'; +export * from './SubscriptionResource'; +export * from './Project'; +export * from './ProjectResource'; +export * from './Sender'; +export * from './SenderResource'; +export * from './Options'; +export * from './PushpadError'; +export { default } from './Pushpad'; +export { Pushpad } from './Pushpad'; From cda51ae03333c4be40b9e869495347f9d4febba4 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Tue, 28 Oct 2025 18:16:16 +0100 Subject: [PATCH 18/25] Rename some types --- types/NotificationResource.d.ts | 8 ++++---- types/ProjectResource.d.ts | 8 ++++---- types/SenderResource.d.ts | 10 +++++----- types/SubscriptionResource.d.ts | 16 ++++++++-------- 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/types/NotificationResource.d.ts b/types/NotificationResource.d.ts index 402515e..b311aab 100644 --- a/types/NotificationResource.d.ts +++ b/types/NotificationResource.d.ts @@ -1,20 +1,20 @@ import type { Notification, NotificationCreateResult } from './Notification'; import type { RequestOptions } from './Options'; -export interface NotificationCreateInput extends Omit< +export interface NotificationCreateParams extends Omit< Notification, 'id' | 'project_id' | 'created_at' | 'successfully_sent_count' | 'opened_count' | 'scheduled_count' | 'scheduled' | 'cancelled' > { body: string; } -export interface NotificationListQuery { +export interface NotificationListParams { page?: number; } export class NotificationResource { - create(data: NotificationCreateInput, options?: RequestOptions): Promise; - findAll(query?: NotificationListQuery, options?: RequestOptions): Promise; + create(data: NotificationCreateParams, options?: RequestOptions): Promise; + findAll(query?: NotificationListParams, options?: RequestOptions): Promise; find(notificationId: number): Promise; cancel(notificationId: number): Promise; } diff --git a/types/ProjectResource.d.ts b/types/ProjectResource.d.ts index e78334b..0349c26 100644 --- a/types/ProjectResource.d.ts +++ b/types/ProjectResource.d.ts @@ -1,14 +1,14 @@ import type { Project } from './Project'; -export interface ProjectCreateInput extends Required>, +export interface ProjectCreateParams extends Required>, Omit {} -export interface ProjectUpdateInput extends Partial> {} +export interface ProjectUpdateParams extends Partial> {} export class ProjectResource { - create(data: ProjectCreateInput): Promise; + create(data: ProjectCreateParams): Promise; findAll(): Promise; find(projectId: number): Promise; - update(projectId: number, data: ProjectUpdateInput): Promise; + update(projectId: number, data: ProjectUpdateParams): Promise; delete(projectId: number): Promise; } diff --git a/types/SenderResource.d.ts b/types/SenderResource.d.ts index 2f74f14..0740893 100644 --- a/types/SenderResource.d.ts +++ b/types/SenderResource.d.ts @@ -1,16 +1,16 @@ import type { Sender } from './Sender'; -export interface SenderCreateInput extends Required>, +export interface SenderCreateParams extends Required>, Omit {} -export interface SenderUpdateInput extends Partial< - Omit +export interface SenderUpdateParams extends Partial< + Omit > {} export class SenderResource { - create(data: SenderCreateInput): Promise; + create(data: SenderCreateParams): Promise; findAll(): Promise; find(senderId: number): Promise; - update(senderId: number, data: SenderUpdateInput): Promise; + update(senderId: number, data: SenderUpdateParams): Promise; delete(senderId: number): Promise; } diff --git a/types/SubscriptionResource.d.ts b/types/SubscriptionResource.d.ts index 358065d..7b78370 100644 --- a/types/SubscriptionResource.d.ts +++ b/types/SubscriptionResource.d.ts @@ -1,18 +1,18 @@ import type { Subscription } from './Subscription'; import type { RequestOptions } from './Options'; -export interface SubscriptionCreateInput extends Omit< +export interface SubscriptionCreateParams extends Omit< Subscription, 'id' | 'project_id' | 'last_click_at' | 'created_at' > { endpoint: string; } -export interface SubscriptionUpdateInput extends Partial< - Omit +export interface SubscriptionUpdateParams extends Partial< + Omit > {} -export interface SubscriptionListQuery { +export interface SubscriptionListParams { page?: number; per_page?: number; uids?: string[]; @@ -20,10 +20,10 @@ export interface SubscriptionListQuery { } export class SubscriptionResource { - create(data: SubscriptionCreateInput, options?: RequestOptions): Promise; - findAll(query?: SubscriptionListQuery, options?: RequestOptions): Promise; - count(query?: Pick, options?: RequestOptions): Promise; + create(data: SubscriptionCreateParams, options?: RequestOptions): Promise; + findAll(query?: SubscriptionListParams, options?: RequestOptions): Promise; + count(query?: Pick, options?: RequestOptions): Promise; find(subscriptionId: number, options?: RequestOptions): Promise; - update(subscriptionId: number, data: SubscriptionUpdateInput, options?: RequestOptions): Promise; + update(subscriptionId: number, data: SubscriptionUpdateParams, options?: RequestOptions): Promise; delete(subscriptionId: number, options?: RequestOptions): Promise; } From e4e3c9cbefc55b56b09f6fcb57d90810ac959724 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Tue, 28 Oct 2025 18:32:59 +0100 Subject: [PATCH 19/25] Define *CreateParams, *UpdateParams types more explicitly without inheritance --- types/NotificationResource.d.ts | 27 ++++++++++++++++++++++----- types/ProjectResource.d.ts | 22 +++++++++++++++++++--- types/SenderResource.d.ts | 13 ++++++++----- types/SubscriptionResource.d.ts | 16 +++++++++------- 4 files changed, 58 insertions(+), 20 deletions(-) diff --git a/types/NotificationResource.d.ts b/types/NotificationResource.d.ts index b311aab..bdb3552 100644 --- a/types/NotificationResource.d.ts +++ b/types/NotificationResource.d.ts @@ -1,11 +1,28 @@ -import type { Notification, NotificationCreateResult } from './Notification'; +import type { + Notification, + NotificationAction, + NotificationCreateResult, +} from './Notification'; import type { RequestOptions } from './Options'; -export interface NotificationCreateParams extends Omit< - Notification, - 'id' | 'project_id' | 'created_at' | 'successfully_sent_count' | 'opened_count' | 'scheduled_count' | 'scheduled' | 'cancelled' -> { +export interface NotificationCreateParams { + title?: string; body: string; + target_url?: string; + icon_url?: string; + badge_url?: string; + image_url?: string; + ttl?: number; + require_interaction?: boolean; + silent?: boolean; + urgent?: boolean; + custom_data?: string; + actions?: NotificationAction[]; + starred?: boolean; + send_at?: string; + custom_metrics?: string[]; + uids?: string[]; + tags?: string[]; } export interface NotificationListParams { diff --git a/types/ProjectResource.d.ts b/types/ProjectResource.d.ts index 0349c26..648e4fd 100644 --- a/types/ProjectResource.d.ts +++ b/types/ProjectResource.d.ts @@ -1,9 +1,25 @@ import type { Project } from './Project'; -export interface ProjectCreateParams extends Required>, - Omit {} +export interface ProjectCreateParams { + sender_id: number; + name: string; + website: string; + icon_url?: string; + badge_url?: string; + notifications_ttl?: number; + notifications_require_interaction?: boolean; + notifications_silent?: boolean; +} -export interface ProjectUpdateParams extends Partial> {} +export interface ProjectUpdateParams { + name?: string; + website?: string; + icon_url?: string; + badge_url?: string; + notifications_ttl?: number; + notifications_require_interaction?: boolean; + notifications_silent?: boolean; +} export class ProjectResource { create(data: ProjectCreateParams): Promise; diff --git a/types/SenderResource.d.ts b/types/SenderResource.d.ts index 0740893..a9a50fc 100644 --- a/types/SenderResource.d.ts +++ b/types/SenderResource.d.ts @@ -1,11 +1,14 @@ import type { Sender } from './Sender'; -export interface SenderCreateParams extends Required>, - Omit {} +export interface SenderCreateParams { + name: string; + vapid_private_key?: string; + vapid_public_key?: string; +} -export interface SenderUpdateParams extends Partial< - Omit -> {} +export interface SenderUpdateParams { + name?: string; +} export class SenderResource { create(data: SenderCreateParams): Promise; diff --git a/types/SubscriptionResource.d.ts b/types/SubscriptionResource.d.ts index 7b78370..bbe79c7 100644 --- a/types/SubscriptionResource.d.ts +++ b/types/SubscriptionResource.d.ts @@ -1,16 +1,18 @@ import type { Subscription } from './Subscription'; import type { RequestOptions } from './Options'; -export interface SubscriptionCreateParams extends Omit< - Subscription, - 'id' | 'project_id' | 'last_click_at' | 'created_at' -> { +export interface SubscriptionCreateParams { endpoint: string; + p256dh?: string; + auth?: string; + uid?: string; + tags?: string[]; } -export interface SubscriptionUpdateParams extends Partial< - Omit -> {} +export interface SubscriptionUpdateParams { + uid?: string; + tags?: string[]; +} export interface SubscriptionListParams { page?: number; From 2248eb75e9bc856a0ac30945679188881317c3e9 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Tue, 28 Oct 2025 19:26:17 +0100 Subject: [PATCH 20/25] Improve types to make them match the API perfectly --- types/Notification.d.ts | 52 ++++++++++++++++----------------- types/NotificationResource.d.ts | 10 +++++-- types/Project.d.ts | 20 ++++++------- types/Sender.d.ts | 10 +++---- types/Subscription.d.ts | 18 ++++++------ 5 files changed, 56 insertions(+), 54 deletions(-) diff --git a/types/Notification.d.ts b/types/Notification.d.ts index a06fbdf..3190b8f 100644 --- a/types/Notification.d.ts +++ b/types/Notification.d.ts @@ -1,36 +1,34 @@ -export interface NotificationAction { - title?: string; - target_url?: string; - icon?: string; - action?: string; -} - export interface Notification { - id?: number; - project_id?: number; - title?: string; - body?: string; - target_url?: string; - icon_url?: string; - badge_url?: string; - image_url?: string; - ttl?: number; - require_interaction?: boolean; - silent?: boolean; - urgent?: boolean; - custom_data?: string; - actions?: NotificationAction[]; - starred?: boolean; - send_at?: string; - custom_metrics?: string[]; - uids?: string[]; - tags?: string[]; - created_at?: string; + id: number; + project_id: number; + title: string; + body: string; + target_url: string; + icon_url: string | null; + badge_url: string | null; + image_url: string | null; + ttl: number; + require_interaction: boolean; + silent: boolean; + urgent: boolean; + custom_data: string | null; + starred: boolean; + send_at: string | null; + custom_metrics: string[]; + uids: string[] | null; + tags: string[] | null; + created_at: string; successfully_sent_count?: number; opened_count?: number; scheduled_count?: number; scheduled?: boolean; cancelled?: boolean; + actions: { + title: string; + target_url?: string | null; + icon?: string | null; + action: string; + }[]; } export interface NotificationCreateResult { diff --git a/types/NotificationResource.d.ts b/types/NotificationResource.d.ts index bdb3552..0b6cb4a 100644 --- a/types/NotificationResource.d.ts +++ b/types/NotificationResource.d.ts @@ -1,13 +1,12 @@ import type { Notification, - NotificationAction, NotificationCreateResult, } from './Notification'; import type { RequestOptions } from './Options'; export interface NotificationCreateParams { - title?: string; body: string; + title?: string; target_url?: string; icon_url?: string; badge_url?: string; @@ -17,12 +16,17 @@ export interface NotificationCreateParams { silent?: boolean; urgent?: boolean; custom_data?: string; - actions?: NotificationAction[]; starred?: boolean; send_at?: string; custom_metrics?: string[]; uids?: string[]; tags?: string[]; + actions?: { + title: string; + target_url?: string; + icon?: string; + action?: string; + }[]; } export interface NotificationListParams { diff --git a/types/Project.d.ts b/types/Project.d.ts index 3a1d6cb..e4be0bf 100644 --- a/types/Project.d.ts +++ b/types/Project.d.ts @@ -1,12 +1,12 @@ export interface Project { - id?: number; - sender_id?: number; - name?: string; - website?: string; - icon_url?: string; - badge_url?: string; - notifications_ttl?: number; - notifications_require_interaction?: boolean; - notifications_silent?: boolean; - created_at?: string; + id: number; + sender_id: number; + name: string; + website: string; + icon_url: string | null; + badge_url: string | null; + notifications_ttl: number; + notifications_require_interaction: boolean; + notifications_silent: boolean; + created_at: string; } diff --git a/types/Sender.d.ts b/types/Sender.d.ts index 4fcf913..b4d1f84 100644 --- a/types/Sender.d.ts +++ b/types/Sender.d.ts @@ -1,7 +1,7 @@ export interface Sender { - id?: number; - name?: string; - vapid_private_key?: string; - vapid_public_key?: string; - created_at?: string; + id: number; + name: string; + vapid_private_key: string; + vapid_public_key: string; + created_at: string; } diff --git a/types/Subscription.d.ts b/types/Subscription.d.ts index 6e2a517..25c8991 100644 --- a/types/Subscription.d.ts +++ b/types/Subscription.d.ts @@ -1,11 +1,11 @@ export interface Subscription { - id?: number; - project_id?: number; - endpoint?: string; - p256dh?: string; - auth?: string; - uid?: string; - tags?: string[]; - last_click_at?: string; - created_at?: string; + id: number; + project_id: number; + endpoint: string; + p256dh: string | null; + auth: string | null; + uid: string | null; + tags: string[]; + last_click_at: string | null; + created_at: string; } From 5ec22e53e33dde1856db6c6847f69742c2567def Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Tue, 28 Oct 2025 21:39:51 +0100 Subject: [PATCH 21/25] Use a single file (index.d.ts) for typescript Avoid issues with import / export and module resolution --- index.d.ts | 220 ++++++++++++++++++++++++++++++++ package.json | 4 +- types/Notification.d.ts | 39 ------ types/NotificationResource.d.ts | 41 ------ types/Options.d.ts | 11 -- types/Project.d.ts | 12 -- types/ProjectResource.d.ts | 30 ----- types/Pushpad.d.ts | 17 --- types/PushpadError.d.ts | 11 -- types/Sender.d.ts | 7 - types/SenderResource.d.ts | 19 --- types/Subscription.d.ts | 11 -- types/SubscriptionResource.d.ts | 31 ----- types/index.d.ts | 12 -- 14 files changed, 222 insertions(+), 243 deletions(-) create mode 100644 index.d.ts delete mode 100644 types/Notification.d.ts delete mode 100644 types/NotificationResource.d.ts delete mode 100644 types/Options.d.ts delete mode 100644 types/Project.d.ts delete mode 100644 types/ProjectResource.d.ts delete mode 100644 types/Pushpad.d.ts delete mode 100644 types/PushpadError.d.ts delete mode 100644 types/Sender.d.ts delete mode 100644 types/SenderResource.d.ts delete mode 100644 types/Subscription.d.ts delete mode 100644 types/SubscriptionResource.d.ts delete mode 100644 types/index.d.ts diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000..5883106 --- /dev/null +++ b/index.d.ts @@ -0,0 +1,220 @@ +export interface PushpadOptions { + authToken: string; + projectId?: number; + baseUrl?: string; + fetch?: typeof fetch; + timeout?: number; +} + +export interface RequestOptions { + projectId?: number; +} + +export interface Notification { + id: number; + project_id: number; + title: string; + body: string; + target_url: string; + icon_url: string | null; + badge_url: string | null; + image_url: string | null; + ttl: number; + require_interaction: boolean; + silent: boolean; + urgent: boolean; + custom_data: string | null; + starred: boolean; + send_at: string | null; + custom_metrics: string[]; + uids: string[] | null; + tags: string[] | null; + created_at: string; + successfully_sent_count?: number; + opened_count?: number; + scheduled_count?: number; + scheduled?: boolean; + cancelled?: boolean; + actions: { + title: string; + target_url?: string | null; + icon?: string | null; + action: string; + }[]; +} + +export interface NotificationCreateResult { + id: number; + scheduled?: number; + uids?: string[]; + send_at?: string; +} + +export interface NotificationCreateParams { + body: string; + title?: string; + target_url?: string; + icon_url?: string; + badge_url?: string; + image_url?: string; + ttl?: number; + require_interaction?: boolean; + silent?: boolean; + urgent?: boolean; + custom_data?: string; + starred?: boolean; + send_at?: string; + custom_metrics?: string[]; + uids?: string[]; + tags?: string[]; + actions?: { + title: string; + target_url?: string; + icon?: string; + action?: string; + }[]; +} + +export interface NotificationListParams { + page?: number; +} + +export class NotificationResource { + create(data: NotificationCreateParams, options?: RequestOptions): Promise; + findAll(query?: NotificationListParams, options?: RequestOptions): Promise; + find(notificationId: number): Promise; + cancel(notificationId: number): Promise; +} + +export interface Subscription { + id: number; + project_id: number; + endpoint: string; + p256dh: string | null; + auth: string | null; + uid: string | null; + tags: string[]; + last_click_at: string | null; + created_at: string; +} + +export interface SubscriptionCreateParams { + endpoint: string; + p256dh?: string; + auth?: string; + uid?: string; + tags?: string[]; +} + +export interface SubscriptionUpdateParams { + uid?: string; + tags?: string[]; +} + +export interface SubscriptionListParams { + page?: number; + per_page?: number; + uids?: string[]; + tags?: string[]; +} + +export class SubscriptionResource { + create(data: SubscriptionCreateParams, options?: RequestOptions): Promise; + findAll(query?: SubscriptionListParams, options?: RequestOptions): Promise; + count(query?: Pick, options?: RequestOptions): Promise; + find(subscriptionId: number, options?: RequestOptions): Promise; + update(subscriptionId: number, data: SubscriptionUpdateParams, options?: RequestOptions): Promise; + delete(subscriptionId: number, options?: RequestOptions): Promise; +} + +export interface Project { + id: number; + sender_id: number; + name: string; + website: string; + icon_url: string | null; + badge_url: string | null; + notifications_ttl: number; + notifications_require_interaction: boolean; + notifications_silent: boolean; + created_at: string; +} + +export interface ProjectCreateParams { + sender_id: number; + name: string; + website: string; + icon_url?: string; + badge_url?: string; + notifications_ttl?: number; + notifications_require_interaction?: boolean; + notifications_silent?: boolean; +} + +export interface ProjectUpdateParams { + name?: string; + website?: string; + icon_url?: string; + badge_url?: string; + notifications_ttl?: number; + notifications_require_interaction?: boolean; + notifications_silent?: boolean; +} + +export class ProjectResource { + create(data: ProjectCreateParams): Promise; + findAll(): Promise; + find(projectId: number): Promise; + update(projectId: number, data: ProjectUpdateParams): Promise; + delete(projectId: number): Promise; +} + +export interface Sender { + id: number; + name: string; + vapid_private_key: string; + vapid_public_key: string; + created_at: string; +} + +export interface SenderCreateParams { + name: string; + vapid_private_key?: string; + vapid_public_key?: string; +} + +export interface SenderUpdateParams { + name?: string; +} + +export class SenderResource { + create(data: SenderCreateParams): Promise; + findAll(): Promise; + find(senderId: number): Promise; + update(senderId: number, data: SenderUpdateParams): Promise; + delete(senderId: number): Promise; +} + +export class PushpadError extends Error { + status: number; + statusText?: string; + body?: unknown; + headers?: Record; + request?: { + method: string; + url: string; + body?: unknown; + }; +} + +declare class Pushpad { + constructor(options: PushpadOptions); + notification: NotificationResource; + subscription: SubscriptionResource; + project: ProjectResource; + sender: SenderResource; + signatureFor(data: string): string; +} + +export default Pushpad; +export { Pushpad }; diff --git a/package.json b/package.json index e10197b..9823988 100644 --- a/package.json +++ b/package.json @@ -7,10 +7,10 @@ "exports": { ".": { "import": "./src/index.js", - "types": "./types/index.d.ts" + "types": "./index.d.ts" } }, - "types": "types/index.d.ts", + "types": "index.d.ts", "keywords": [ "pushpad", "web-push", diff --git a/types/Notification.d.ts b/types/Notification.d.ts deleted file mode 100644 index 3190b8f..0000000 --- a/types/Notification.d.ts +++ /dev/null @@ -1,39 +0,0 @@ -export interface Notification { - id: number; - project_id: number; - title: string; - body: string; - target_url: string; - icon_url: string | null; - badge_url: string | null; - image_url: string | null; - ttl: number; - require_interaction: boolean; - silent: boolean; - urgent: boolean; - custom_data: string | null; - starred: boolean; - send_at: string | null; - custom_metrics: string[]; - uids: string[] | null; - tags: string[] | null; - created_at: string; - successfully_sent_count?: number; - opened_count?: number; - scheduled_count?: number; - scheduled?: boolean; - cancelled?: boolean; - actions: { - title: string; - target_url?: string | null; - icon?: string | null; - action: string; - }[]; -} - -export interface NotificationCreateResult { - id: number; - scheduled?: number; - uids?: string[]; - send_at?: string; -} diff --git a/types/NotificationResource.d.ts b/types/NotificationResource.d.ts deleted file mode 100644 index 0b6cb4a..0000000 --- a/types/NotificationResource.d.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { - Notification, - NotificationCreateResult, -} from './Notification'; -import type { RequestOptions } from './Options'; - -export interface NotificationCreateParams { - body: string; - title?: string; - target_url?: string; - icon_url?: string; - badge_url?: string; - image_url?: string; - ttl?: number; - require_interaction?: boolean; - silent?: boolean; - urgent?: boolean; - custom_data?: string; - starred?: boolean; - send_at?: string; - custom_metrics?: string[]; - uids?: string[]; - tags?: string[]; - actions?: { - title: string; - target_url?: string; - icon?: string; - action?: string; - }[]; -} - -export interface NotificationListParams { - page?: number; -} - -export class NotificationResource { - create(data: NotificationCreateParams, options?: RequestOptions): Promise; - findAll(query?: NotificationListParams, options?: RequestOptions): Promise; - find(notificationId: number): Promise; - cancel(notificationId: number): Promise; -} diff --git a/types/Options.d.ts b/types/Options.d.ts deleted file mode 100644 index 6593245..0000000 --- a/types/Options.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -export interface RequestOptions { - projectId?: number; -} - -export interface PushpadOptions { - authToken: string; - projectId?: number; - baseUrl?: string; - fetch?: typeof fetch; - timeout?: number; -} diff --git a/types/Project.d.ts b/types/Project.d.ts deleted file mode 100644 index e4be0bf..0000000 --- a/types/Project.d.ts +++ /dev/null @@ -1,12 +0,0 @@ -export interface Project { - id: number; - sender_id: number; - name: string; - website: string; - icon_url: string | null; - badge_url: string | null; - notifications_ttl: number; - notifications_require_interaction: boolean; - notifications_silent: boolean; - created_at: string; -} diff --git a/types/ProjectResource.d.ts b/types/ProjectResource.d.ts deleted file mode 100644 index 648e4fd..0000000 --- a/types/ProjectResource.d.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { Project } from './Project'; - -export interface ProjectCreateParams { - sender_id: number; - name: string; - website: string; - icon_url?: string; - badge_url?: string; - notifications_ttl?: number; - notifications_require_interaction?: boolean; - notifications_silent?: boolean; -} - -export interface ProjectUpdateParams { - name?: string; - website?: string; - icon_url?: string; - badge_url?: string; - notifications_ttl?: number; - notifications_require_interaction?: boolean; - notifications_silent?: boolean; -} - -export class ProjectResource { - create(data: ProjectCreateParams): Promise; - findAll(): Promise; - find(projectId: number): Promise; - update(projectId: number, data: ProjectUpdateParams): Promise; - delete(projectId: number): Promise; -} diff --git a/types/Pushpad.d.ts b/types/Pushpad.d.ts deleted file mode 100644 index 795a2db..0000000 --- a/types/Pushpad.d.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { PushpadOptions } from './Options'; -import type { NotificationResource } from './NotificationResource'; -import type { SubscriptionResource } from './SubscriptionResource'; -import type { ProjectResource } from './ProjectResource'; -import type { SenderResource } from './SenderResource'; - -declare class Pushpad { - constructor(options: PushpadOptions); - notification: NotificationResource; - subscription: SubscriptionResource; - project: ProjectResource; - sender: SenderResource; - signatureFor(data: string): string; -} - -export default Pushpad; -export { Pushpad }; diff --git a/types/PushpadError.d.ts b/types/PushpadError.d.ts deleted file mode 100644 index 5d70524..0000000 --- a/types/PushpadError.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -export class PushpadError extends Error { - status: number; - statusText?: string; - body?: unknown; - headers?: Record; - request?: { - method: string; - url: string; - body?: unknown; - }; -} diff --git a/types/Sender.d.ts b/types/Sender.d.ts deleted file mode 100644 index b4d1f84..0000000 --- a/types/Sender.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -export interface Sender { - id: number; - name: string; - vapid_private_key: string; - vapid_public_key: string; - created_at: string; -} diff --git a/types/SenderResource.d.ts b/types/SenderResource.d.ts deleted file mode 100644 index a9a50fc..0000000 --- a/types/SenderResource.d.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { Sender } from './Sender'; - -export interface SenderCreateParams { - name: string; - vapid_private_key?: string; - vapid_public_key?: string; -} - -export interface SenderUpdateParams { - name?: string; -} - -export class SenderResource { - create(data: SenderCreateParams): Promise; - findAll(): Promise; - find(senderId: number): Promise; - update(senderId: number, data: SenderUpdateParams): Promise; - delete(senderId: number): Promise; -} diff --git a/types/Subscription.d.ts b/types/Subscription.d.ts deleted file mode 100644 index 25c8991..0000000 --- a/types/Subscription.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -export interface Subscription { - id: number; - project_id: number; - endpoint: string; - p256dh: string | null; - auth: string | null; - uid: string | null; - tags: string[]; - last_click_at: string | null; - created_at: string; -} diff --git a/types/SubscriptionResource.d.ts b/types/SubscriptionResource.d.ts deleted file mode 100644 index bbe79c7..0000000 --- a/types/SubscriptionResource.d.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { Subscription } from './Subscription'; -import type { RequestOptions } from './Options'; - -export interface SubscriptionCreateParams { - endpoint: string; - p256dh?: string; - auth?: string; - uid?: string; - tags?: string[]; -} - -export interface SubscriptionUpdateParams { - uid?: string; - tags?: string[]; -} - -export interface SubscriptionListParams { - page?: number; - per_page?: number; - uids?: string[]; - tags?: string[]; -} - -export class SubscriptionResource { - create(data: SubscriptionCreateParams, options?: RequestOptions): Promise; - findAll(query?: SubscriptionListParams, options?: RequestOptions): Promise; - count(query?: Pick, options?: RequestOptions): Promise; - find(subscriptionId: number, options?: RequestOptions): Promise; - update(subscriptionId: number, data: SubscriptionUpdateParams, options?: RequestOptions): Promise; - delete(subscriptionId: number, options?: RequestOptions): Promise; -} diff --git a/types/index.d.ts b/types/index.d.ts deleted file mode 100644 index 0238971..0000000 --- a/types/index.d.ts +++ /dev/null @@ -1,12 +0,0 @@ -export * from './Notification'; -export * from './NotificationResource'; -export * from './Subscription'; -export * from './SubscriptionResource'; -export * from './Project'; -export * from './ProjectResource'; -export * from './Sender'; -export * from './SenderResource'; -export * from './Options'; -export * from './PushpadError'; -export { default } from './Pushpad'; -export { Pushpad } from './Pushpad'; From 84b018810dbe1f62718131fbc537a6c5455cb83a Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Wed, 29 Oct 2025 14:34:50 +0100 Subject: [PATCH 22/25] Add notification.send (alias for notification.create) --- index.d.ts | 1 + src/resources/notifications.js | 10 ++++++++++ test/notifications.test.js | 15 +++++++++++++++ 3 files changed, 26 insertions(+) diff --git a/index.d.ts b/index.d.ts index 5883106..4542892 100644 --- a/index.d.ts +++ b/index.d.ts @@ -81,6 +81,7 @@ export interface NotificationListParams { export class NotificationResource { create(data: NotificationCreateParams, options?: RequestOptions): Promise; + send(data: NotificationCreateParams, options?: RequestOptions): Promise; findAll(query?: NotificationListParams, options?: RequestOptions): Promise; find(notificationId: number): Promise; cancel(notificationId: number): Promise; diff --git a/src/resources/notifications.js b/src/resources/notifications.js index 668b7f8..46ea953 100644 --- a/src/resources/notifications.js +++ b/src/resources/notifications.js @@ -22,6 +22,16 @@ export class NotificationResource extends ResourceBase { }); } + /** + * Alias for create. + * @param {Record} data + * @param {{ projectId?: number }} [options] + * @returns {Promise>} + */ + async send(data, options) { + return this.create(data, options); + } + /** * Lists notifications for a project. * @param {{ page?: number }} [query] diff --git a/test/notifications.test.js b/test/notifications.test.js index f669e8c..30a6115 100644 --- a/test/notifications.test.js +++ b/test/notifications.test.js @@ -20,6 +20,21 @@ test('notification.create creates a notification', async () => { assert.equal(call.options.body, JSON.stringify({ body: 'Hello world' })); }); +test('notification.send is an alias for notification.create', async () => { + const fetchStub = createFetchStub([ + { status: 201, body: { id: 55 } } + ]); + + const client = new Pushpad({ authToken: 'token', projectId: 99, fetch: fetchStub }); + const response = await client.notification.send({ body: 'Alias test' }); + + assert.deepEqual(response, { id: 55 }); + const { call, url } = parseLastCall(fetchStub); + assert.equal(call.options.method, 'POST'); + assert.equal(url.pathname, '/api/v1/projects/99/notifications'); + assert.equal(call.options.body, JSON.stringify({ body: 'Alias test' })); +}); + test('notification.findAll lists notifications for the default project', async () => { const fetchStub = createFetchStub([ { status: 200, body: [{ id: 1 }, { id: 2 }] } From b4296601da5634900806a6fa65b83f69cf0b7549 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Wed, 29 Oct 2025 14:48:26 +0100 Subject: [PATCH 23/25] Add sentence to README --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 3bda386..7cc31b5 100755 --- a/README.md +++ b/README.md @@ -118,7 +118,13 @@ pushpad.signatureFor(currentUserId); ## Sending push notifications +Use `pushpad.notification.create()` (or the `send()` alias) to create and send a notification: + ```javascript +// send a simple notification +await pushpad.notification.send({ body: 'Your message' }); + +// a more complex notification with all the optional fields const payload = { // required, the main content of the notification body: 'Hello world!', From 349f7859dcc9dbd97457de9aaf6abd012dc2f022 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Wed, 29 Oct 2025 15:28:50 +0100 Subject: [PATCH 24/25] Remove cache: 'npm' from CI --- .github/workflows/ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5acdf3d..cb95e43 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,5 @@ jobs: uses: actions/setup-node@v5 with: node-version: ${{ matrix.node-version }} - cache: 'npm' - run: npm install - run: npm test From 3f9fbc5c764d4c3bd29744d1fb2b849765041094 Mon Sep 17 00:00:00 2001 From: Marco Colli Date: Wed, 29 Oct 2025 15:46:06 +0100 Subject: [PATCH 25/25] Update node version in CI --- .github/workflows/ci.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb95e43..478737d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,11 +9,12 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [22.x, 23.x] + # include even-numbered releases (LTS) + node-version: [22.x, 24.x] steps: - uses: actions/checkout@v5 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v5 + uses: actions/setup-node@v6 with: node-version: ${{ matrix.node-version }} - run: npm install