diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5acdf3d..478737d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,13 +9,13 @@ 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 }} - cache: 'npm' - run: npm install - run: npm test diff --git a/README.md b/README.md index fa2762d..7cc31b5 100755 --- a/README.md +++ b/README.md @@ -1,35 +1,111 @@ # 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 +``` + +## 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. -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 +113,19 @@ 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 -}); +Use `pushpad.notification.create()` (or the `send()` alias) to create and send a notification: -var notification = new pushpad.Notification({ - project: project, +```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!', @@ -63,23 +133,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 +158,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 +176,310 @@ 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 +``` + +## 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); +``` + +## Error handling + +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) { + 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' }); +``` -- `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. +## 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 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). 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..4542892 --- /dev/null +++ b/index.d.ts @@ -0,0 +1,221 @@ +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; + send(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/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..9823988 --- a/package.json +++ b/package.json @@ -1,22 +1,28 @@ { "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" + } }, + "types": "index.d.ts", + "keywords": [ + "pushpad", + "web-push", + "notifications", + "api-client" + ], "license": "MIT", - "dependencies": { - "superagent": "^10.1.1" - }, - "devDependencies": { - "mocha": "^11.0.1", - "nock": "^13.5.6" + "engines": { + "node": ">=20" }, "scripts": { - "test": "mocha" - } + "test": "node --test" + }, + "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..4fa629d --- /dev/null +++ b/src/httpClient.js @@ -0,0 +1,225 @@ +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] + * @property {boolean} [includeHeaders] + */ + +/** + * 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, + includeHeaders + } = 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 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')); + + 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: responseHeaders, + request: { method, url: url.toString(), body } + }); + } + + if (includeHeaders) { + return { + body: parsedBody, + headers: responseHeaders, + status: response.status + }; + } + + return parsedBody; + } +} + +export default HttpClient; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..01e9d27 --- /dev/null +++ b/src/index.js @@ -0,0 +1,59 @@ +import { createHmac } from 'node:crypto'; +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} [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.authToken = authToken; + this.client = new HttpClient({ authToken, baseUrl, fetch, timeout }); + + 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); + } + + /** + * 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 }; +export default Pushpad; diff --git a/src/resources/base.js b/src/resources/base.js new file mode 100644 index 0000000..5e03eef --- /dev/null +++ b/src/resources/base.js @@ -0,0 +1,42 @@ +/** + * Base class providing helpers shared across Pushpad API resources. + */ +export class ResourceBase { + /** + * @param {import('../httpClient.js').HttpClient} client + * @param {number | undefined} defaultProjectId + */ + constructor(client, defaultProjectId) { + this.client = client; + this.defaultProjectId = defaultProjectId; + } + + /** + * Resolves the project id to be used for a request. + * @param {{ projectId?: number }} [options] + * @returns {string} + */ + requireProjectId(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.'); + } + 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..46ea953 --- /dev/null +++ b/src/resources/notifications.js @@ -0,0 +1,75 @@ +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 }} [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 + }); + } + + /** + * 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] + * @param {{ projectId?: number }} [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} 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} 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..51681b2 --- /dev/null +++ b/src/resources/projects.js @@ -0,0 +1,78 @@ +import { ResourceBase } from './base.js'; + +/** + * Provides access to Pushpad project endpoints. + */ +export class ProjectResource extends ResourceBase { + + /** + * 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} 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} 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} 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..2a74078 --- /dev/null +++ b/src/resources/senders.js @@ -0,0 +1,78 @@ +import { ResourceBase } from './base.js'; + +/** + * Provides access to Pushpad sender endpoints. + */ +export class SenderResource extends ResourceBase { + + /** + * 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} 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} 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} 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..ae7d9db --- /dev/null +++ b/src/resources/subscriptions.js @@ -0,0 +1,146 @@ +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; + } + + 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 }} [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, + * uids?: string | string[], + * tags?: string | string[] + * }} [query] + * @param {{ projectId?: number }} [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 + }); + } + + /** + * 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 + * @param {{ projectId?: number }} [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} subscriptionId + * @param {Record} data + * @param {{ projectId?: number }} [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} subscriptionId + * @param {{ projectId?: number }} [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/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..fd77038 --- /dev/null +++ b/test/httpClient.test.js @@ -0,0 +1,112 @@ +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); +}); + +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/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/notifications.test.js b/test/notifications.test.js new file mode 100644 index 0000000..30a6115 --- /dev/null +++ b/test/notifications.test.js @@ -0,0 +1,121 @@ +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 creates a notification', 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.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 }] } + ]); + + 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: [] } + ]); + + 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.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 }); + + 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); +}); + +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 new file mode 100644 index 0000000..8103154 --- /dev/null +++ b/test/projects.test.js @@ -0,0 +1,118 @@ +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 creates a project', 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 lists 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 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 }); + + 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 updates a 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 deletes a project', 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'); +}); + +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/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); - }); - }); - -}); 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); +}); diff --git a/test/senders.test.js b/test/senders.test.js new file mode 100644 index 0000000..a97c6ec --- /dev/null +++ b/test/senders.test.js @@ -0,0 +1,111 @@ +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 creates a sender', 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 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 }); + + 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 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 }); + + 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 updates a sender', 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 deletes a sender', 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'); +}); + +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 new file mode 100644 index 0000000..a09286c --- /dev/null +++ b/test/subscriptions.test.js @@ -0,0 +1,170 @@ +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.create creates a subscription', 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.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({ per_page: 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.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 }]); + 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 }); + + 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 deletes a subscription', 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.create requires an object payload', async () => { + const fetchStub = createFetchStub(); + const client = new Pushpad({ authToken: 'token', projectId: 88, fetch: fetchStub }); + + await assert.rejects( + client.subscription.create(), + (error) => { + assert(error instanceof Error); + 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); +});