| /** | ||
| * `devices` resource client. | ||
| * | ||
| * Mounted on `Nitroping` as `np.devices`. Wraps | ||
| * `POST /api/v1/devices` and `DELETE /api/v1/devices/:id`. | ||
| */ | ||
| import type { HttpClient } from "./http.mjs"; | ||
| import type { RegisterDeviceRequest, RegisterDeviceResponse } from "./types.mjs"; | ||
| export declare class DevicesClient { | ||
| private readonly http; | ||
| constructor(http: HttpClient); | ||
| /** | ||
| * Register (or update) a device with the secret API key. | ||
| * | ||
| * Idempotent on `(app_id, token, user_id)`. Returns `created: true` | ||
| * when a new row was inserted, `created: false` when an existing | ||
| * device matched. | ||
| */ | ||
| register(input: RegisterDeviceRequest): Promise<RegisterDeviceResponse>; | ||
| /** | ||
| * Deactivate a device (soft delete — sets `status = inactive`). | ||
| * | ||
| * Returns `{ id, status: "inactive" }`. Throws a `NitropingError` | ||
| * with `code: "not_found"` if the id doesn't belong to your app. | ||
| */ | ||
| deactivate(id: string): Promise<{ | ||
| id: string; | ||
| status: string; | ||
| }>; | ||
| } |
| export class DevicesClient { | ||
| http; | ||
| constructor(http) { | ||
| this.http = http; | ||
| } | ||
| async register(input) { | ||
| return await this.http.request("POST", "/api/v1/devices", { body: toWire(input) }); | ||
| } | ||
| async deactivate(id) { | ||
| return await this.http.request("DELETE", `/api/v1/devices/${encodeURIComponent(id)}`); | ||
| } | ||
| } | ||
| function toWire(input) { | ||
| const wire = { | ||
| token: input.token, | ||
| platform: input.platform | ||
| }; | ||
| if (input.userId !== undefined) wire["user_id"] = input.userId; | ||
| if (input.webPushP256dh !== undefined) wire["web_push_p256dh"] = input.webPushP256dh; | ||
| if (input.webPushAuth !== undefined) wire["web_push_auth"] = input.webPushAuth; | ||
| if (input.metadata !== undefined) wire["metadata"] = input.metadata; | ||
| return wire; | ||
| } |
| /** | ||
| * Error hierarchy for the nitroping SDK. | ||
| * | ||
| * All public functions throw subclasses of `NitropingError`. Catch the | ||
| * base class to handle every error, or narrow by `instanceof` on the | ||
| * specific subclass when you want to switch on a known failure mode | ||
| * (e.g. retry on `NetworkError`, surface a UI prompt on | ||
| * `PermissionDeniedError`). | ||
| */ | ||
| /** | ||
| * Base error thrown by every SDK function. Subclasses set `name` and may | ||
| * add structured fields (`status`, `code`, `details`). | ||
| */ | ||
| export declare class NitropingError extends Error { | ||
| override readonly name: string; | ||
| /** Optional HTTP status if this error originated from a response. */ | ||
| readonly status?: number; | ||
| /** | ||
| * Stable machine-readable code, mirrored from the server envelope | ||
| * (`error.code`). Examples: `"invalid_api_key"`, `"validation_failed"`, | ||
| * `"quota_exceeded"`. SDK-internal failures use codes like | ||
| * `"network_error"` or `"invalid_signature"`. | ||
| */ | ||
| readonly code: string; | ||
| /** | ||
| * Free-form details object — typically the server's | ||
| * `error.details` (field-level validation errors). | ||
| */ | ||
| readonly details?: unknown; | ||
| constructor(message: string, options?: { | ||
| status?: number; | ||
| code?: string; | ||
| details?: unknown; | ||
| cause?: unknown; | ||
| }); | ||
| } | ||
| /** | ||
| * Thrown when `fetch` itself rejects (DNS, TLS, abort, offline). The | ||
| * underlying error is attached via `cause`. | ||
| */ | ||
| export declare class NetworkError extends NitropingError { | ||
| override readonly name = "NetworkError"; | ||
| constructor(message: string, cause?: unknown); | ||
| } | ||
| /** | ||
| * Thrown by `verifyWebhook` when the computed HMAC does not match the | ||
| * `v1=` value in the `X-Nitroping-Signature` header, or the header is | ||
| * missing / malformed. | ||
| */ | ||
| export declare class InvalidSignatureError extends NitropingError { | ||
| override readonly name = "InvalidSignatureError"; | ||
| constructor(message?: string); | ||
| } | ||
| /** | ||
| * Thrown by `verifyWebhook` when the signature is well-formed and | ||
| * matches the body, but its `t=` timestamp is outside the tolerance | ||
| * window. Defends against signature replay. | ||
| */ | ||
| export declare class TimestampOutOfRangeError extends NitropingError { | ||
| override readonly name = "TimestampOutOfRangeError"; | ||
| constructor(message?: string); | ||
| } | ||
| /** | ||
| * Thrown by `subscribeWebPush` when the browser lacks one of the | ||
| * required APIs (Service Worker, Push API, `crypto.subtle`). | ||
| */ | ||
| export declare class WebPushUnsupportedError extends NitropingError { | ||
| override readonly name = "WebPushUnsupportedError"; | ||
| constructor(message?: string); | ||
| } | ||
| /** | ||
| * Thrown by `subscribeWebPush` when the user (or browser policy) | ||
| * denies the notification permission. | ||
| */ | ||
| export declare class PermissionDeniedError extends NitropingError { | ||
| override readonly name = "PermissionDeniedError"; | ||
| constructor(message?: string); | ||
| } |
| export class NitropingError extends Error { | ||
| name = "NitropingError"; | ||
| status; | ||
| code; | ||
| details; | ||
| constructor(message, options = {}) { | ||
| super(message, options.cause === undefined ? undefined : { cause: options.cause }); | ||
| this.status = options.status; | ||
| this.code = options.code ?? "error"; | ||
| this.details = options.details; | ||
| } | ||
| } | ||
| export class NetworkError extends NitropingError { | ||
| name = "NetworkError"; | ||
| constructor(message, cause) { | ||
| super(message, { | ||
| code: "network_error", | ||
| cause | ||
| }); | ||
| } | ||
| } | ||
| export class InvalidSignatureError extends NitropingError { | ||
| name = "InvalidSignatureError"; | ||
| constructor(message = "Webhook signature does not match request body") { | ||
| super(message, { code: "invalid_signature" }); | ||
| } | ||
| } | ||
| export class TimestampOutOfRangeError extends NitropingError { | ||
| name = "TimestampOutOfRangeError"; | ||
| constructor(message = "Webhook timestamp is outside the allowed tolerance") { | ||
| super(message, { code: "timestamp_out_of_range" }); | ||
| } | ||
| } | ||
| export class WebPushUnsupportedError extends NitropingError { | ||
| name = "WebPushUnsupportedError"; | ||
| constructor(message = "Web Push is not supported in this environment") { | ||
| super(message, { code: "web_push_unsupported" }); | ||
| } | ||
| } | ||
| export class PermissionDeniedError extends NitropingError { | ||
| name = "PermissionDeniedError"; | ||
| constructor(message = "Notification permission was denied") { | ||
| super(message, { code: "permission_denied" }); | ||
| } | ||
| } |
| /** Default base URL pointing at the hosted nitroping service. */ | ||
| export declare const DEFAULT_BASE_URL = "https://nitroping.dev"; | ||
| /** | ||
| * Constructor options shared between server and public-key clients. | ||
| */ | ||
| export interface HttpClientOptions { | ||
| /** | ||
| * Secret API key (`np_...`) or public key (`pk_...`). Sent in the | ||
| * `Authorization` header. The scheme (`ApiKey` vs `Public`) is set | ||
| * by `authScheme`. | ||
| */ | ||
| apiKey: string; | ||
| /** Base URL. Defaults to `https://nitroping.dev`. */ | ||
| baseUrl?: string; | ||
| /** | ||
| * Per-request timeout in milliseconds. Default: 30_000. | ||
| * | ||
| * Set to `0` to disable. Implemented with `AbortController` so it | ||
| * works in every runtime. | ||
| */ | ||
| timeoutMs?: number; | ||
| /** Custom `fetch` implementation. Useful for dependency injection in tests. */ | ||
| fetch?: typeof fetch; | ||
| /** | ||
| * Authorization scheme. `ApiKey` for the server SDK, `Public` for | ||
| * the browser-side public-key flow. | ||
| */ | ||
| authScheme?: "ApiKey" | "Public"; | ||
| /** | ||
| * Custom `User-Agent` header. Ignored in browsers (the runtime sets | ||
| * it itself). | ||
| */ | ||
| userAgent?: string; | ||
| } | ||
| /** Internal: a structured HTTP client. */ | ||
| export declare class HttpClient { | ||
| readonly baseUrl: string; | ||
| readonly apiKey: string; | ||
| readonly timeoutMs: number; | ||
| readonly fetchImpl: typeof fetch; | ||
| readonly authScheme: "ApiKey" | "Public"; | ||
| readonly userAgent: string; | ||
| constructor(opts: HttpClientOptions); | ||
| /** Perform an HTTP request and parse the JSON envelope. */ | ||
| request<T>(method: string, path: string, options?: { | ||
| body?: unknown; | ||
| headers?: Record<string, string>; | ||
| query?: Record<string, string | number | boolean | undefined>; | ||
| }): Promise<T>; | ||
| private buildUrl; | ||
| } |
+100
| import { NetworkError, NitropingError } from "./errors.mjs"; | ||
| export const DEFAULT_BASE_URL = "https://nitroping.dev"; | ||
| export class HttpClient { | ||
| baseUrl; | ||
| apiKey; | ||
| timeoutMs; | ||
| fetchImpl; | ||
| authScheme; | ||
| userAgent; | ||
| constructor(opts) { | ||
| if (!opts.apiKey) { | ||
| throw new NitropingError("apiKey is required", { code: "invalid_argument" }); | ||
| } | ||
| this.apiKey = opts.apiKey; | ||
| this.baseUrl = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ""); | ||
| this.timeoutMs = opts.timeoutMs ?? 3e4; | ||
| this.authScheme = opts.authScheme ?? "ApiKey"; | ||
| this.userAgent = opts.userAgent ?? "nitroping-js/0.1.0"; | ||
| const f = opts.fetch ?? globalThis.fetch; | ||
| if (typeof f !== "function") { | ||
| throw new NitropingError("Global `fetch` is not available. Pass a `fetch` implementation in the SDK constructor options.", { code: "fetch_unavailable" }); | ||
| } | ||
| this.fetchImpl = f.bind(globalThis); | ||
| } | ||
| async request(method, path, options = {}) { | ||
| const url = this.buildUrl(path, options.query); | ||
| const headers = { | ||
| Authorization: `${this.authScheme} ${this.apiKey}`, | ||
| Accept: "application/json", | ||
| ...options.headers | ||
| }; | ||
| let bodyText; | ||
| if (options.body !== undefined) { | ||
| bodyText = JSON.stringify(options.body); | ||
| headers["Content-Type"] = "application/json"; | ||
| } | ||
| if (typeof window === "undefined" && this.userAgent && !headers["User-Agent"]) { | ||
| headers["User-Agent"] = this.userAgent; | ||
| } | ||
| const controller = this.timeoutMs > 0 ? new AbortController() : undefined; | ||
| const timer = controller !== undefined ? setTimeout(() => controller.abort(), this.timeoutMs) : undefined; | ||
| let response; | ||
| try { | ||
| response = await this.fetchImpl(url, { | ||
| method, | ||
| headers, | ||
| body: bodyText, | ||
| signal: controller?.signal | ||
| }); | ||
| } catch (cause) { | ||
| throw new NetworkError(`Request to ${url} failed: ${cause?.message ?? cause}`, cause); | ||
| } finally { | ||
| if (timer !== undefined) clearTimeout(timer); | ||
| } | ||
| return await parseResponse(response); | ||
| } | ||
| buildUrl(path, query) { | ||
| const url = new URL(path.startsWith("/") ? path : `/${path}`, `${this.baseUrl}/`); | ||
| if (query) { | ||
| for (const [k, v] of Object.entries(query)) { | ||
| if (v !== undefined) url.searchParams.set(k, String(v)); | ||
| } | ||
| } | ||
| return url.toString(); | ||
| } | ||
| } | ||
| async function parseResponse(response) { | ||
| const text = await response.text(); | ||
| let json = undefined; | ||
| if (text.length > 0) { | ||
| try { | ||
| json = JSON.parse(text); | ||
| } catch { | ||
| if (!response.ok) { | ||
| throw new NitropingError(`HTTP ${response.status} ${response.statusText}: ${text.slice(0, 200)}`, { | ||
| status: response.status, | ||
| code: `http_${response.status}` | ||
| }); | ||
| } | ||
| return text; | ||
| } | ||
| } | ||
| if (!response.ok) { | ||
| const envelope = json ?? {}; | ||
| const err = envelope.error ?? {}; | ||
| throw new NitropingError(err.message ?? `HTTP ${response.status}`, { | ||
| status: response.status, | ||
| code: err.code ?? `http_${response.status}`, | ||
| details: err.details | ||
| }); | ||
| } | ||
| return json; | ||
| } |
| /** | ||
| * Server SDK entry point — `import { Nitroping } from "nitroping"`. | ||
| * | ||
| * Re-exports the main class, both resource clients (so you can type | ||
| * helper functions against them), the error hierarchy, and every | ||
| * public type. | ||
| */ | ||
| export { DevicesClient } from "./devices.mjs"; | ||
| export { InvalidSignatureError, NetworkError, NitropingError, PermissionDeniedError, TimestampOutOfRangeError, WebPushUnsupportedError } from "./errors.mjs"; | ||
| export { DEFAULT_BASE_URL } from "./http.mjs"; | ||
| export { Nitroping, type NitropingOptions } from "./nitroping.mjs"; | ||
| export { NotificationsClient, type SendOptions } from "./notifications.mjs"; | ||
| export type { NotificationAction, NotificationResponse, NotificationTarget, Platform, RegisterDeviceRequest, RegisterDeviceResponse, SendNotificationRequest, WebhookEvent } from "./types.mjs"; |
| export { DevicesClient } from "./devices.mjs"; | ||
| export { InvalidSignatureError, NetworkError, NitropingError, PermissionDeniedError, TimestampOutOfRangeError, WebPushUnsupportedError } from "./errors.mjs"; | ||
| export { DEFAULT_BASE_URL } from "./http.mjs"; | ||
| export { Nitroping } from "./nitroping.mjs"; | ||
| export { NotificationsClient } from "./notifications.mjs"; |
| /** | ||
| * `Nitroping` — the main server-side SDK entry point. | ||
| * | ||
| * @example | ||
| * ```ts | ||
| * import { Nitroping } from "nitroping" | ||
| * | ||
| * const np = new Nitroping({ apiKey: process.env.NITROPING_API_KEY! }) | ||
| * | ||
| * await np.notifications.send({ | ||
| * title: "Order #4129 shipped", | ||
| * body: "On its way", | ||
| * target: { all: true }, | ||
| * }) | ||
| * ``` | ||
| */ | ||
| import { DevicesClient } from "./devices.mjs"; | ||
| import { HttpClient, type HttpClientOptions } from "./http.mjs"; | ||
| import { NotificationsClient } from "./notifications.mjs"; | ||
| /** Constructor options for `Nitroping`. */ | ||
| export interface NitropingOptions extends Omit<HttpClientOptions, "apiKey" | "authScheme"> { | ||
| /** | ||
| * Secret API key (`np_...`). | ||
| * | ||
| * Falls back to `process.env.NITROPING_API_KEY` when omitted (Node / | ||
| * Bun / Deno only — browser code should pass it explicitly or, better, | ||
| * use `nitroping/web` with a public `pk_` key). | ||
| */ | ||
| apiKey?: string; | ||
| } | ||
| export declare class Nitroping { | ||
| /** `notifications` resource — send, get. */ | ||
| readonly notifications: NotificationsClient; | ||
| /** `devices` resource — register, deactivate. */ | ||
| readonly devices: DevicesClient; | ||
| /** Internal HTTP client. Exposed for advanced use (custom requests). */ | ||
| readonly http: HttpClient; | ||
| constructor(options?: NitropingOptions); | ||
| } |
| import { DevicesClient } from "./devices.mjs"; | ||
| import { NitropingError } from "./errors.mjs"; | ||
| import { HttpClient } from "./http.mjs"; | ||
| import { NotificationsClient } from "./notifications.mjs"; | ||
| export class Nitroping { | ||
| notifications; | ||
| devices; | ||
| http; | ||
| constructor(options = {}) { | ||
| const apiKey = options.apiKey ?? readEnv("NITROPING_API_KEY"); | ||
| if (!apiKey) { | ||
| throw new NitropingError("apiKey is required. Pass it to `new Nitroping({apiKey})` or set the NITROPING_API_KEY environment variable.", { code: "invalid_argument" }); | ||
| } | ||
| this.http = new HttpClient({ | ||
| ...options, | ||
| apiKey, | ||
| authScheme: "ApiKey" | ||
| }); | ||
| this.notifications = new NotificationsClient(this.http); | ||
| this.devices = new DevicesClient(this.http); | ||
| } | ||
| } | ||
| function readEnv(name) { | ||
| const proc = globalThis.process; | ||
| if (proc?.env && typeof proc.env[name] === "string") { | ||
| return proc.env[name]; | ||
| } | ||
| const deno = globalThis.Deno; | ||
| if (deno && typeof deno.env?.get === "function") { | ||
| return deno.env.get(name); | ||
| } | ||
| return undefined; | ||
| } |
| /** | ||
| * `notifications` resource client. | ||
| * | ||
| * Mounted on `Nitroping` as `np.notifications`. Wraps | ||
| * `POST /api/v1/notifications` and `GET /api/v1/notifications/:id`. | ||
| */ | ||
| import type { HttpClient } from "./http.mjs"; | ||
| import type { NotificationResponse, SendNotificationRequest } from "./types.mjs"; | ||
| /** Per-call overrides for `send()`. */ | ||
| export interface SendOptions { | ||
| /** | ||
| * Optional `Idempotency-Key`. If the same key + the same body is sent | ||
| * again the server replays the cached response. Same key + different | ||
| * body returns a 409 (`idempotency_conflict`). | ||
| * | ||
| * Max 255 characters. Pick something stable + unique for the logical | ||
| * operation (e.g. `order-shipped-4129`). | ||
| */ | ||
| idempotencyKey?: string; | ||
| } | ||
| export declare class NotificationsClient { | ||
| private readonly http; | ||
| constructor(http: HttpClient); | ||
| /** | ||
| * Enqueue a new notification. | ||
| * | ||
| * Returns `{ id, status }` on `201 Created`. On non-2xx the SDK | ||
| * throws a `NitropingError` carrying the server's `code`, `message`, | ||
| * and (for validation failures) the per-field `details` map. | ||
| */ | ||
| send(input: SendNotificationRequest, options?: SendOptions): Promise<NotificationResponse>; | ||
| /** Fetch a previously enqueued notification by id. */ | ||
| get(id: string): Promise<Record<string, unknown>>; | ||
| } |
| export class NotificationsClient { | ||
| http; | ||
| constructor(http) { | ||
| this.http = http; | ||
| } | ||
| async send(input, options = {}) { | ||
| const headers = {}; | ||
| if (options.idempotencyKey !== undefined) { | ||
| headers["Idempotency-Key"] = options.idempotencyKey; | ||
| } | ||
| return await this.http.request("POST", "/api/v1/notifications", { | ||
| body: toWire(input), | ||
| headers | ||
| }); | ||
| } | ||
| async get(id) { | ||
| return await this.http.request("GET", `/api/v1/notifications/${encodeURIComponent(id)}`); | ||
| } | ||
| } | ||
| function toWire(input) { | ||
| const wire = {}; | ||
| if (input.title !== undefined) wire["title"] = input.title; | ||
| if (input.body !== undefined) wire["body"] = input.body; | ||
| if (input.template !== undefined) wire["template"] = input.template; | ||
| if (input.vars !== undefined) wire["vars"] = input.vars; | ||
| if (input.data !== undefined) wire["data"] = input.data; | ||
| if (input.icon !== undefined) wire["icon"] = input.icon; | ||
| if (input.image !== undefined) wire["image"] = input.image; | ||
| if (input.clickAction !== undefined) wire["click_action"] = input.clickAction; | ||
| if (input.deepLink !== undefined) wire["deep_link"] = input.deepLink; | ||
| if (input.actions !== undefined) wire["actions"] = input.actions; | ||
| if (input.scheduledAt !== undefined) wire["scheduled_at"] = input.scheduledAt; | ||
| if (input.expiresAt !== undefined) wire["expires_at"] = input.expiresAt; | ||
| wire["target"] = targetToWire(input.target); | ||
| return wire; | ||
| } | ||
| function targetToWire(target) { | ||
| if ("all" in target) return { all: target.all }; | ||
| if ("deviceIds" in target) return { device_ids: target.deviceIds }; | ||
| if ("userIds" in target) return { user_ids: target.userIds }; | ||
| return target; | ||
| } |
+103
| /** | ||
| * Shared request/response types for the nitroping HTTP API. | ||
| * | ||
| * The wire shape mirrors `POST /api/v1/notifications` on the | ||
| * nitroping-pro server. Field names are snake_case on the wire; the | ||
| * SDK accepts camelCase on input and converts at the boundary. | ||
| */ | ||
| /** Supported device platforms. */ | ||
| export type Platform = "ios" | "android" | "web"; | ||
| /** Target selector for a notification. Exactly one of the three. */ | ||
| export type NotificationTarget = { | ||
| all: true; | ||
| } | { | ||
| deviceIds: string[]; | ||
| } | { | ||
| userIds: string[]; | ||
| }; | ||
| /** Action button rendered on the notification (where the platform supports it). */ | ||
| export interface NotificationAction { | ||
| /** Stable id reported back in `notification.clicked` events. */ | ||
| id: string; | ||
| /** Button label shown to the user. */ | ||
| title: string; | ||
| /** Optional icon URL. */ | ||
| icon?: string; | ||
| } | ||
| /** | ||
| * Request body for `POST /api/v1/notifications`. | ||
| * | ||
| * Either `title + body` (raw payload) or `template + vars` | ||
| * (Pro plan). Mixing the two is a 422. | ||
| */ | ||
| export interface SendNotificationRequest { | ||
| /** Push notification title. */ | ||
| title?: string; | ||
| /** Push notification body / message. */ | ||
| body?: string; | ||
| /** Template slug — alternative to `title + body`. Requires Pro tier. */ | ||
| template?: string; | ||
| /** Variables interpolated into the template. */ | ||
| vars?: Record<string, unknown>; | ||
| /** Custom payload delivered alongside the visible push. */ | ||
| data?: Record<string, unknown>; | ||
| /** Notification icon URL. */ | ||
| icon?: string; | ||
| /** Notification image URL. */ | ||
| image?: string; | ||
| /** Legacy fallback URL when tapped. Prefer `deepLink`. */ | ||
| clickAction?: string; | ||
| /** URL or app deep link opened when the user taps the notification. */ | ||
| deepLink?: string; | ||
| /** Action buttons (where supported). */ | ||
| actions?: NotificationAction[]; | ||
| /** ISO-8601 timestamp; the row is held until then by the cron worker. */ | ||
| scheduledAt?: string; | ||
| /** ISO-8601 timestamp; after this the notification is dropped. */ | ||
| expiresAt?: string; | ||
| /** Where to send the notification. */ | ||
| target: NotificationTarget; | ||
| } | ||
| /** Response from `POST /api/v1/notifications`. */ | ||
| export interface NotificationResponse { | ||
| /** UUID of the notification row. */ | ||
| id: string; | ||
| /** Initial status, usually `"queued"`. */ | ||
| status: string; | ||
| } | ||
| /** Request body for `POST /api/v1/devices` (secret-key device register). */ | ||
| export interface RegisterDeviceRequest { | ||
| /** APNs token, FCM token, or Web Push endpoint URL. */ | ||
| token: string; | ||
| /** Device platform. */ | ||
| platform: Platform; | ||
| /** Opaque tenant-side user id. */ | ||
| userId?: string; | ||
| /** Required when `platform = "web"`. */ | ||
| webPushP256dh?: string; | ||
| /** Required when `platform = "web"`. */ | ||
| webPushAuth?: string; | ||
| /** Arbitrary key-value pairs stored alongside the device row. */ | ||
| metadata?: Record<string, unknown>; | ||
| } | ||
| /** Response from `POST /api/v1/devices`. */ | ||
| export interface RegisterDeviceResponse { | ||
| /** UUID of the device row. */ | ||
| id: string; | ||
| /** `true` if the device was created on this request; `false` if it already existed. */ | ||
| created: boolean; | ||
| } | ||
| /** | ||
| * Outbound webhook event envelope (parsed from a `verifyWebhook` call). | ||
| * Matches the structure built in `Nitroping.Webhooks.Outbound.dispatch/3`. | ||
| */ | ||
| export interface WebhookEvent { | ||
| /** Event id, prefixed with `evt_`. */ | ||
| id: string; | ||
| /** Event type, e.g. `"notification.delivered"`, `"webhook.test"`. */ | ||
| type: string; | ||
| /** ISO-8601 timestamp set when the event was queued. */ | ||
| created_at: string; | ||
| /** Event-specific payload. */ | ||
| data: Record<string, unknown>; | ||
| } |
| export {}; |
| /** Options for `subscribeWebPush`. */ | ||
| export interface SubscribeWebPushOptions { | ||
| /** Public API key (`pk_...`) for your app. */ | ||
| publicKey: string; | ||
| /** UUID of the app. Used to fetch the VAPID public key. */ | ||
| appId: string; | ||
| /** | ||
| * Path that `navigator.serviceWorker.register` will load. Default: | ||
| * `/sw.js`. The script itself must respond to `push` events — see | ||
| * the README for a minimal implementation. | ||
| */ | ||
| serviceWorkerPath?: string; | ||
| /** Optional registration scope. Defaults to whatever the SW path implies. */ | ||
| serviceWorkerScope?: string; | ||
| /** | ||
| * Opaque tenant-side user id. Stored on the device row so you can | ||
| * later target `{ user_ids: [...] }`. | ||
| */ | ||
| userId?: string; | ||
| /** | ||
| * Override the API base URL. Defaults to `https://nitroping.dev`. | ||
| * Useful when running against a staging deployment. | ||
| */ | ||
| baseUrl?: string; | ||
| /** Override the global `fetch` for testing. */ | ||
| fetch?: typeof fetch; | ||
| /** Override the global `navigator` for testing. */ | ||
| navigatorRef?: Navigator; | ||
| } | ||
| /** Result of a successful `subscribeWebPush` call. */ | ||
| export interface SubscribeWebPushResult { | ||
| /** Registered device row. */ | ||
| device: { | ||
| id: string; | ||
| endpoint: string; | ||
| }; | ||
| /** Raw PushSubscription returned by the browser. */ | ||
| subscription: PushSubscription; | ||
| } | ||
| /** | ||
| * Subscribe the current browser to push and register the resulting | ||
| * endpoint with nitroping. Idempotent — call it on every page load; | ||
| * the server dedupes on `(app_id, token)`. | ||
| */ | ||
| export declare function subscribeWebPush(options: SubscribeWebPushOptions): Promise<SubscribeWebPushResult>; |
+143
| import { NetworkError, NitropingError, PermissionDeniedError, WebPushUnsupportedError } from "./errors.mjs"; | ||
| import { DEFAULT_BASE_URL } from "./http.mjs"; | ||
| export async function subscribeWebPush(options) { | ||
| const nav = options.navigatorRef ?? (typeof navigator !== "undefined" ? navigator : undefined); | ||
| const win = typeof window !== "undefined" ? window : undefined; | ||
| const fetchImpl = options.fetch ?? (typeof fetch !== "undefined" ? fetch : undefined); | ||
| const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ""); | ||
| const swPath = options.serviceWorkerPath ?? "/sw.js"; | ||
| if (!nav || !("serviceWorker" in nav) || typeof win === "undefined" || !("PushManager" in win)) { | ||
| throw new WebPushUnsupportedError(); | ||
| } | ||
| if (typeof fetchImpl !== "function") { | ||
| throw new WebPushUnsupportedError("Global `fetch` is not available"); | ||
| } | ||
| const registration = await nav.serviceWorker.register(swPath, options.serviceWorkerScope === undefined ? undefined : { scope: options.serviceWorkerScope }); | ||
| await waitForActive(registration); | ||
| const permission = await requestPermission(win); | ||
| if (permission !== "granted") { | ||
| throw new PermissionDeniedError(`Notification permission is ${permission}`); | ||
| } | ||
| const vapidUrl = `${baseUrl}/api/v1/public/apps/${encodeURIComponent(options.appId)}/vapid`; | ||
| let vapidResponse; | ||
| try { | ||
| vapidResponse = await fetchImpl(vapidUrl, { | ||
| method: "GET", | ||
| headers: { Accept: "application/json" } | ||
| }); | ||
| } catch (cause) { | ||
| throw new NetworkError(`Failed to fetch VAPID public key: ${cause?.message ?? cause}`, cause); | ||
| } | ||
| if (!vapidResponse.ok) { | ||
| const text = await vapidResponse.text().catch(() => ""); | ||
| throw new NitropingError(`Failed to fetch VAPID public key (HTTP ${vapidResponse.status}): ${text || vapidResponse.statusText}`, { | ||
| status: vapidResponse.status, | ||
| code: "vapid_fetch_failed" | ||
| }); | ||
| } | ||
| const { public_key: vapidPublicKey } = await vapidResponse.json(); | ||
| if (!vapidPublicKey) { | ||
| throw new NitropingError("VAPID response missing `public_key`", { code: "vapid_invalid" }); | ||
| } | ||
| let subscription; | ||
| try { | ||
| subscription = await registration.pushManager.subscribe({ | ||
| userVisibleOnly: true, | ||
| applicationServerKey: urlBase64ToUint8Array(vapidPublicKey) | ||
| }); | ||
| } catch (cause) { | ||
| throw new NitropingError(`pushManager.subscribe failed: ${cause?.message ?? cause}`, { | ||
| code: "subscribe_failed", | ||
| cause | ||
| }); | ||
| } | ||
| const subJson = subscription.toJSON(); | ||
| const endpoint = subJson.endpoint ?? subscription.endpoint; | ||
| const keys = subJson.keys ?? {}; | ||
| const registerBody = { | ||
| platform: "web", | ||
| token: endpoint, | ||
| web_push_p256dh: keys.p256dh, | ||
| web_push_auth: keys.auth, | ||
| user_id: options.userId | ||
| }; | ||
| let registerResponse; | ||
| try { | ||
| registerResponse = await fetchImpl(`${baseUrl}/api/v1/public/devices`, { | ||
| method: "POST", | ||
| headers: { | ||
| Authorization: `Public ${options.publicKey}`, | ||
| "Content-Type": "application/json", | ||
| Accept: "application/json" | ||
| }, | ||
| body: JSON.stringify(registerBody) | ||
| }); | ||
| } catch (cause) { | ||
| throw new NetworkError(`Failed to register device: ${cause?.message ?? cause}`, cause); | ||
| } | ||
| const registerText = await registerResponse.text(); | ||
| let registerJson = {}; | ||
| if (registerText) { | ||
| try { | ||
| registerJson = JSON.parse(registerText); | ||
| } catch {} | ||
| } | ||
| if (!registerResponse.ok) { | ||
| throw new NitropingError(registerJson.error?.message ?? `Device register failed (HTTP ${registerResponse.status})`, { | ||
| status: registerResponse.status, | ||
| code: registerJson.error?.code ?? `http_${registerResponse.status}` | ||
| }); | ||
| } | ||
| if (!registerJson.id) { | ||
| throw new NitropingError("Device register response missing `id`", { code: "register_invalid" }); | ||
| } | ||
| return { | ||
| device: { | ||
| id: registerJson.id, | ||
| endpoint | ||
| }, | ||
| subscription | ||
| }; | ||
| } | ||
| async function waitForActive(registration) { | ||
| if (registration.active) return; | ||
| const installing = registration.installing ?? registration.waiting; | ||
| if (!installing) return; | ||
| await new Promise((resolve) => { | ||
| const listener = () => { | ||
| if (installing.state === "activated") { | ||
| installing.removeEventListener("statechange", listener); | ||
| resolve(); | ||
| } | ||
| }; | ||
| installing.addEventListener("statechange", listener); | ||
| }); | ||
| } | ||
| async function requestPermission(win) { | ||
| const N = win.Notification; | ||
| if (!N) throw new WebPushUnsupportedError("Notification API is not available"); | ||
| if (N.permission === "granted") return "granted"; | ||
| if (N.permission === "denied") return "denied"; | ||
| return await N.requestPermission(); | ||
| } | ||
| function urlBase64ToUint8Array(base64) { | ||
| const padding = "=".repeat((4 - base64.length % 4) % 4); | ||
| const normalized = (base64 + padding).replace(/-/g, "+").replace(/_/g, "/"); | ||
| const raw = atob(normalized); | ||
| const out = new Uint8Array(raw.length); | ||
| for (let i = 0; i < raw.length; i++) { | ||
| out[i] = raw.charCodeAt(i); | ||
| } | ||
| return out; | ||
| } |
| import type { WebhookEvent } from "./types.mjs"; | ||
| export type { WebhookEvent } from "./types.mjs"; | ||
| /** Options accepted by `verifyWebhook`. */ | ||
| export interface VerifyWebhookOptions { | ||
| /** | ||
| * Raw request body, exactly as received (do **not** re-stringify a | ||
| * parsed JSON object — whitespace and key order matter to the HMAC). | ||
| * | ||
| * `Uint8Array` is accepted for non-UTF-8-safe pipelines, but the | ||
| * standard path is a UTF-8 string read off the request before | ||
| * JSON-parsing it. | ||
| */ | ||
| body: string | Uint8Array; | ||
| /** | ||
| * The `X-Nitroping-Signature` header value, or `null` / `undefined` | ||
| * if the header was missing (treated as `InvalidSignatureError`). | ||
| */ | ||
| signature: string | string[] | null | undefined; | ||
| /** Webhook signing secret, configured in the app panel. */ | ||
| secret: string; | ||
| /** | ||
| * Maximum drift between `t=` and the verifier's wall clock, in | ||
| * seconds. Default: 300 (five minutes). Set lower for stricter | ||
| * replay defense. | ||
| */ | ||
| tolerance?: number; | ||
| /** | ||
| * Override "now" — useful for tests + replaying a saved request | ||
| * during incident investigation. | ||
| */ | ||
| now?: Date | number; | ||
| } | ||
| /** | ||
| * Verify and parse a webhook delivery. | ||
| * | ||
| * Returns the parsed `WebhookEvent` on success. Throws: | ||
| * | ||
| * - `InvalidSignatureError` — header missing, malformed, or HMAC mismatch | ||
| * - `TimestampOutOfRangeError` — signature valid but `t=` outside `tolerance` | ||
| * - `NitropingError` — body wasn't valid JSON | ||
| */ | ||
| export declare function verifyWebhook(options: VerifyWebhookOptions): Promise<WebhookEvent>; | ||
| /** | ||
| * Compute a header value for the nitroping signing scheme. Mostly | ||
| * useful for tests; production code should rely on the server. | ||
| */ | ||
| export declare function signWebhook(secret: string, body: string, timestamp?: Date | number): Promise<string>; |
| import { InvalidSignatureError, NitropingError, TimestampOutOfRangeError } from "./errors.mjs"; | ||
| export async function verifyWebhook(options) { | ||
| const tolerance = options.tolerance ?? 300; | ||
| const now = toUnixSeconds(options.now ?? new Date()); | ||
| const header = pickHeader(options.signature); | ||
| if (!header) throw new InvalidSignatureError("Missing X-Nitroping-Signature header"); | ||
| const parsed = parseSignatureHeader(header); | ||
| if (!parsed) { | ||
| throw new InvalidSignatureError("Malformed X-Nitroping-Signature header"); | ||
| } | ||
| const rawBody = typeof options.body === "string" ? options.body : utf8Decode(options.body); | ||
| const expected = await hmacSha256Hex(options.secret, `${parsed.t}.${rawBody}`); | ||
| if (!timingSafeEqualHex(expected, parsed.v1)) { | ||
| throw new InvalidSignatureError(); | ||
| } | ||
| if (Math.abs(now - parsed.t) > tolerance) { | ||
| throw new TimestampOutOfRangeError(`Webhook timestamp ${parsed.t} is more than ${tolerance}s from now (${now})`); | ||
| } | ||
| let event; | ||
| try { | ||
| event = JSON.parse(rawBody); | ||
| } catch (cause) { | ||
| throw new NitropingError("Webhook body is not valid JSON", { | ||
| code: "invalid_body", | ||
| cause | ||
| }); | ||
| } | ||
| return event; | ||
| } | ||
| export async function signWebhook(secret, body, timestamp) { | ||
| const t = toUnixSeconds(timestamp ?? new Date()); | ||
| const v1 = await hmacSha256Hex(secret, `${t}.${body}`); | ||
| return `t=${t}, v1=${v1}`; | ||
| } | ||
| function pickHeader(input) { | ||
| if (input === null || input === undefined) return undefined; | ||
| if (Array.isArray(input)) return input[0]; | ||
| return input; | ||
| } | ||
| function parseSignatureHeader(header) { | ||
| const parts = header.split(",").map((s) => s.trim()); | ||
| let t; | ||
| let v1; | ||
| for (const part of parts) { | ||
| const eq = part.indexOf("="); | ||
| if (eq <= 0) continue; | ||
| const key = part.slice(0, eq).trim(); | ||
| const value = part.slice(eq + 1).trim(); | ||
| if (key === "t") { | ||
| const n = Number.parseInt(value, 10); | ||
| if (!Number.isFinite(n)) return undefined; | ||
| t = n; | ||
| } else if (key === "v1") { | ||
| if (!/^[0-9a-f]+$/i.test(value)) return undefined; | ||
| v1 = value.toLowerCase(); | ||
| } | ||
| } | ||
| if (t === undefined || v1 === undefined) return undefined; | ||
| return { | ||
| t, | ||
| v1 | ||
| }; | ||
| } | ||
| async function hmacSha256Hex(secret, message) { | ||
| const enc = new TextEncoder(); | ||
| const keyBytes = enc.encode(secret); | ||
| const msgBytes = enc.encode(message); | ||
| const cryptoObj = globalThis.crypto; | ||
| if (!cryptoObj || !cryptoObj.subtle) { | ||
| throw new NitropingError("Web Crypto (crypto.subtle) is not available in this runtime. Node 18+, Bun, Deno, and modern browsers all provide it.", { code: "subtle_unavailable" }); | ||
| } | ||
| const key = await cryptoObj.subtle.importKey("raw", keyBytes, { | ||
| name: "HMAC", | ||
| hash: "SHA-256" | ||
| }, false, ["sign"]); | ||
| const sig = await cryptoObj.subtle.sign("HMAC", key, msgBytes); | ||
| return bufferToHex(sig); | ||
| } | ||
| function bufferToHex(buf) { | ||
| const view = new Uint8Array(buf); | ||
| let out = ""; | ||
| for (let i = 0; i < view.length; i++) { | ||
| const b = view[i]; | ||
| out += (b < 16 ? "0" : "") + b.toString(16); | ||
| } | ||
| return out; | ||
| } | ||
| function timingSafeEqualHex(a, b) { | ||
| if (a.length !== b.length) return false; | ||
| let diff = 0; | ||
| for (let i = 0; i < a.length; i++) { | ||
| diff |= a.charCodeAt(i) ^ b.charCodeAt(i); | ||
| } | ||
| return diff === 0; | ||
| } | ||
| function toUnixSeconds(value) { | ||
| if (typeof value === "number") { | ||
| return value > 0xe8d4a51000 ? Math.floor(value / 1e3) : Math.floor(value); | ||
| } | ||
| return Math.floor(value.getTime() / 1e3); | ||
| } | ||
| function utf8Decode(bytes) { | ||
| return new TextDecoder("utf-8").decode(bytes); | ||
| } |
+21
| MIT License | ||
| Copyright (c) 2026 productdevbook | ||
| Permission is hereby granted, free of charge, to any person obtaining a copy | ||
| of this software and associated documentation files (the "Software"), to deal | ||
| in the Software without restriction, including without limitation the rights | ||
| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||
| copies of the Software, and to permit persons to whom the Software is | ||
| furnished to do so, subject to the following conditions: | ||
| The above copyright notice and this permission notice shall be included in all | ||
| copies or substantial portions of the Software. | ||
| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||
| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||
| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||
| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||
| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||
| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||
| SOFTWARE. |
+59
-32
| { | ||
| "name": "nitroping", | ||
| "type": "module", | ||
| "version": "0.2.0", | ||
| "description": "JavaScript/TypeScript SDK for NitroPing push notification service", | ||
| "author": "NitroPing Team", | ||
| "version": "0.2.2", | ||
| "description": "Zero-dependency TypeScript SDK for nitroping push notifications. Send pushes, register devices, verify webhooks. Works in Node, Bun, Deno, Cloudflare Workers, browsers.", | ||
| "keywords": [ | ||
| "apns", | ||
| "esm", | ||
| "fcm", | ||
| "notifications", | ||
| "push", | ||
| "sdk", | ||
| "tree-shakeable", | ||
| "typescript", | ||
| "web-push", | ||
| "zero-dependency" | ||
| ], | ||
| "homepage": "https://github.com/productdevbook/nitroping-sdk/tree/main/js", | ||
| "bugs": { | ||
| "url": "https://github.com/productdevbook/nitroping-sdk/issues" | ||
| }, | ||
| "license": "MIT", | ||
| "author": "productdevbook", | ||
| "repository": { | ||
| "type": "git", | ||
| "url": "https://github.com/productdevbook/nitroping.git", | ||
| "directory": "sdk" | ||
| "url": "git+https://github.com/productdevbook/nitroping-sdk.git", | ||
| "directory": "js" | ||
| }, | ||
| "keywords": [ | ||
| "push-notifications", | ||
| "web-push", | ||
| "vapid", | ||
| "notification", | ||
| "nitroping" | ||
| "funding": "https://github.com/sponsors/productdevbook", | ||
| "files": [ | ||
| "dist" | ||
| ], | ||
| "type": "module", | ||
| "module": "./dist/index.mjs", | ||
| "types": "./dist/index.d.mts", | ||
| "exports": { | ||
| ".": { | ||
| "types": "./dist/index.d.ts", | ||
| "import": "./dist/index.js" | ||
| "types": "./dist/index.d.mts", | ||
| "default": "./dist/index.mjs" | ||
| }, | ||
| "./web": { | ||
| "types": "./dist/web.d.mts", | ||
| "default": "./dist/web.mjs" | ||
| }, | ||
| "./webhooks": { | ||
| "types": "./dist/webhooks.d.mts", | ||
| "default": "./dist/webhooks.mjs" | ||
| } | ||
| }, | ||
| "types": "./dist/index.d.ts", | ||
| "files": [ | ||
| "dist" | ||
| ], | ||
| "engines": { | ||
| "node": ">=24" | ||
| "publishConfig": { | ||
| "access": "public", | ||
| "provenance": true | ||
| }, | ||
| "devDependencies": { | ||
| "@typescript/native-preview": "7.0.0-dev.20260316.1", | ||
| "@vitest/coverage-v8": "^4.1.1", | ||
| "bumpp": "^11.0.1", | ||
| "obuild": "^0.4.32", | ||
| "oxfmt": "^0.42.0", | ||
| "oxlint": "^1.57.0", | ||
| "typescript": "^6.0.2", | ||
| "vitest": "^4.1.1" | ||
| }, | ||
| "scripts": { | ||
| "build": "tsdown", | ||
| "dev": "tsdown --watch", | ||
| "postinstall": "tsdown", | ||
| "typecheck": "tsc --noEmit", | ||
| "clean": "rm -rf dist", | ||
| "release": "bun run build && bun publish --no-git-checks --access public" | ||
| }, | ||
| "peerDependencies": {}, | ||
| "devDependencies": { | ||
| "tsdown": "^0.20.3", | ||
| "typescript": "^5.9.3" | ||
| "build": "obuild", | ||
| "dev": "vitest", | ||
| "lint": "oxlint . && oxfmt --check .", | ||
| "lint:fix": "oxlint . --fix && oxfmt .", | ||
| "fmt": "oxfmt .", | ||
| "test": "pnpm lint && pnpm typecheck && vitest run", | ||
| "typecheck": "tsgo --noEmit", | ||
| "release": "pnpm test && pnpm build && bumpp --commit --tag --push --all" | ||
| } | ||
| } | ||
| } |
+380
-103
@@ -1,9 +0,44 @@ | ||
| # nitroping | ||
| > This package is part of the [**nitroping-sdk**](https://github.com/productdevbook/nitroping-sdk) monorepo. | ||
| > The npm package name (`nitroping`) is unchanged. See the [top-level README](../README.md) for SDKs in other languages. | ||
| JavaScript/TypeScript SDK for NitroPing push notification service. | ||
| <p align="center"> | ||
| <br> | ||
| <b style="font-size: 2em;">nitroping-js</b> | ||
| <br><br> | ||
| Zero-dependency TypeScript SDK for <a href="https://nitroping.dev">nitroping</a>. | ||
| <br> | ||
| Send push notifications, register devices, verify webhooks. Pure ESM, works in Node, Bun, Deno, Cloudflare Workers, and browsers. | ||
| <br><br> | ||
| <a href="https://npmjs.com/package/nitroping"><img src="https://img.shields.io/npm/v/nitroping?style=flat&colorA=18181B&colorB=34d399" alt="npm version"></a> | ||
| <a href="https://npmjs.com/package/nitroping"><img src="https://img.shields.io/npm/dm/nitroping?style=flat&colorA=18181B&colorB=34d399" alt="npm downloads"></a> | ||
| <a href="https://bundlephobia.com/result?p=nitroping"><img src="https://img.shields.io/bundlephobia/minzip/nitroping?style=flat&colorA=18181B&colorB=34d399" alt="bundle size"></a> | ||
| <a href="https://github.com/productdevbook/nitroping-sdk/blob/main/LICENSE"><img src="https://img.shields.io/github/license/productdevbook/nitroping-sdk?style=flat&colorA=18181B&colorB=34d399" alt="license"></a> | ||
| <a href="https://github.com/productdevbook/nitroping-sdk/stargazers"><img src="https://img.shields.io/github/stars/productdevbook/nitroping-sdk?style=flat&colorA=18181B&colorB=34d399" alt="GitHub stars"></a> | ||
| </p> | ||
| ## Installation | ||
| ## Why nitroping? | ||
| ```bash | ||
| [nitroping](https://nitroping.dev) is a hosted push notification service that | ||
| unifies APNs (iOS), FCM (Android), and Web Push behind one API. Send to a | ||
| single device, a user across all of their devices, or every device in your app | ||
| with one HTTP call. The service handles fanout, retries, idempotency, quota | ||
| and outbound webhooks for delivery state — you write the product, not the | ||
| plumbing. | ||
| `nitroping-js` is the official TypeScript client. It has **zero runtime | ||
| dependencies**, ships as native ESM with full type definitions, and runs | ||
| anywhere modern JavaScript runs: Node 18+, Bun, Deno, Cloudflare Workers, | ||
| Vercel Edge, and the browser. The whole bundle is small enough to drop into | ||
| serverless without thinking about it. | ||
| ## Install | ||
| ```sh | ||
| npm install nitroping | ||
| # or | ||
| pnpm add nitroping | ||
| # or | ||
| bun add nitroping | ||
| # or | ||
| yarn add nitroping | ||
| ``` | ||
@@ -13,145 +48,387 @@ | ||
| ```typescript | ||
| import { NitroPingClient } from 'nitroping' | ||
| ### Send a notification (server) | ||
| // Initialize the client | ||
| const client = new NitroPingClient({ | ||
| appId: 'your-app-id', | ||
| vapidPublicKey: 'your-vapid-public-key', | ||
| apiUrl: 'https://api.yoursite.com', // optional, defaults to localhost:3000 | ||
| ```ts | ||
| import { Nitroping } from "nitroping" | ||
| const np = new Nitroping({ apiKey: process.env.NITROPING_API_KEY! }) | ||
| const result = await np.notifications.send( | ||
| { | ||
| title: "Order #4129 shipped", | ||
| body: "Your package is on its way.", | ||
| deepLink: "https://example.com/orders/4129", | ||
| actions: [ | ||
| { id: "track", title: "Track" }, | ||
| { id: "view", title: "View order" }, | ||
| ], | ||
| target: { userIds: ["user-42"] }, | ||
| }, | ||
| { idempotencyKey: "order-shipped-4129" }, | ||
| ) | ||
| console.log(result.id, result.status) // "abc-...", "queued" | ||
| ``` | ||
| ### Register a Web Push device (browser) | ||
| ```ts | ||
| import { subscribeWebPush } from "nitroping/web" | ||
| const { device } = await subscribeWebPush({ | ||
| publicKey: "pk_live_...", | ||
| appId: "0e1d2c3b-4a59-6877-9876-543210abcdef", | ||
| userId: "user-42", | ||
| }) | ||
| // Subscribe to push notifications | ||
| try { | ||
| const device = await client.subscribe({ | ||
| userId: 'user-123', // optional | ||
| tags: ['news', 'updates'], // optional | ||
| }) | ||
| console.log('Subscribed successfully:', device) | ||
| console.log("Subscribed device", device.id) | ||
| ``` | ||
| Then drop a tiny `/public/sw.js` that handles `push`: | ||
| ```js | ||
| self.addEventListener("push", (event) => { | ||
| const data = event.data?.json() ?? {} | ||
| event.waitUntil( | ||
| self.registration.showNotification(data.title ?? "Notification", { | ||
| body: data.body, | ||
| icon: data.icon, | ||
| data: { deepLink: data.deep_link }, | ||
| }), | ||
| ) | ||
| }) | ||
| self.addEventListener("notificationclick", (event) => { | ||
| event.notification.close() | ||
| const url = event.notification.data?.deepLink ?? "/" | ||
| event.waitUntil(self.clients.openWindow(url)) | ||
| }) | ||
| ``` | ||
| ### Verify a webhook (server) | ||
| ```ts | ||
| import { verifyWebhook } from "nitroping/webhooks" | ||
| export async function POST(request: Request) { | ||
| const body = await request.text() | ||
| const signature = request.headers.get("x-nitroping-signature") | ||
| try { | ||
| const event = await verifyWebhook({ | ||
| body, | ||
| signature, | ||
| secret: process.env.NITROPING_WEBHOOK_SECRET!, | ||
| }) | ||
| if (event.type === "notification.delivered") { | ||
| console.log("delivered", event.data["notification_id"]) | ||
| } | ||
| } catch (err) { | ||
| return new Response("signature error", { status: 400 }) | ||
| } | ||
| return new Response("ok") | ||
| } | ||
| catch (error) { | ||
| console.error('Subscription failed:', error) | ||
| } | ||
| ``` | ||
| // Check subscription status | ||
| const status = await client.getSubscriptionStatus() | ||
| console.log('Is subscribed:', status.isSubscribed) | ||
| ## Tree shaking | ||
| // Unsubscribe | ||
| await client.unsubscribe() | ||
| Three independent entry points — import only what you need: | ||
| ```ts | ||
| import { Nitroping } from "nitroping" // server: send + devices | ||
| import { subscribeWebPush } from "nitroping/web" // browser: subscribe + register | ||
| import { verifyWebhook } from "nitroping/webhooks" // server: webhook verify | ||
| ``` | ||
| ## API Reference | ||
| The `web` and `webhooks` modules don't pull the HTTP client in, so a server | ||
| that only verifies webhooks doesn't ship any request code, and a browser app | ||
| that only subscribes doesn't ship anything secret-key-flavored. | ||
| ### NitroPingClient | ||
| ## API reference | ||
| #### Constructor | ||
| ### `new Nitroping(options)` | ||
| ```typescript | ||
| new NitroPingClient(config: NitroPingConfig) | ||
| Creates a server-side client. | ||
| ```ts | ||
| const np = new Nitroping({ | ||
| apiKey: "np_live_...", // or omit + set NITROPING_API_KEY env var | ||
| baseUrl: "https://nitroping.dev", // optional, default shown | ||
| timeoutMs: 30_000, // optional, default 30s. 0 = disable. | ||
| }) | ||
| ``` | ||
| **Parameters:** | ||
| - `config.appId` (string, required): Your NitroPing app ID | ||
| - `config.vapidPublicKey` (string, required): Your VAPID public key | ||
| - `config.apiUrl` (string, optional): API base URL (defaults to localhost:3000) | ||
| - `config.userId` (string, optional): Default user ID for subscriptions | ||
| #### `np.notifications.send(input, options?)` | ||
| #### Methods | ||
| Sends a notification. Returns `{ id, status }`. Throws `NitropingError` on | ||
| non-2xx with the server's `code`, `message`, and per-field `details`. | ||
| ##### `isSupported(): boolean` | ||
| Checks if push notifications are supported in the current environment. | ||
| ```ts | ||
| await np.notifications.send( | ||
| { | ||
| title: "Welcome!", | ||
| body: "Glad to have you on board.", | ||
| icon: "https://example.com/icon.png", | ||
| image: "https://example.com/hero.png", | ||
| deepLink: "https://example.com/welcome", | ||
| data: { onboarding: true }, | ||
| actions: [{ id: "tour", title: "Take the tour" }], | ||
| target: { all: true }, | ||
| }, | ||
| { idempotencyKey: "welcome-user-42" }, | ||
| ) | ||
| ``` | ||
| ##### `getPermissionStatus(): NotificationPermission` | ||
| Gets the current notification permission status. | ||
| `target` is a discriminated union — exactly one of: | ||
| ##### `requestPermission(): Promise<NotificationPermission>` | ||
| Requests notification permission from the user. | ||
| | Selector | Use when | | ||
| | ---------------------- | -------------------------------- | | ||
| | `{ all: true }` | Broadcast to every active device | | ||
| | `{ deviceIds: [...] }` | Hit specific device rows | | ||
| | `{ userIds: [...] }` | Hit every device row a user owns | | ||
| ##### `subscribe(options?: SubscriptionOptions): Promise<DeviceRegistration>` | ||
| Subscribes to push notifications. | ||
| #### `np.notifications.get(id)` | ||
| **Options:** | ||
| - `userId` (string, optional): User ID for this subscription | ||
| - `tags` (string[], optional): Tags to associate with this subscription | ||
| - `metadata` (Record<string, any>, optional): Additional metadata | ||
| Fetch a previously-enqueued notification by id. Returns the full row | ||
| (with counters: `total_sent`, `total_delivered`, `total_failed`, etc). | ||
| ##### `unsubscribe(): Promise<boolean>` | ||
| Unsubscribes from push notifications. | ||
| ```ts | ||
| const n = await np.notifications.get("abc-123") | ||
| console.log(n["counters"]) | ||
| ``` | ||
| ##### `isSubscribed(): Promise<boolean>` | ||
| Checks if the user is currently subscribed. | ||
| #### `np.devices.register(input)` | ||
| ##### `getSubscriptionStatus(): Promise<SubscriptionStatus>` | ||
| Gets detailed subscription status and information. | ||
| Register a device with the **secret** API key. Use this for iOS / Android | ||
| where you control the server. Returns `{ id, created }` — `created` is | ||
| `false` when an existing row matched on `(token, user_id)`. | ||
| ## Requirements | ||
| ```ts | ||
| await np.devices.register({ | ||
| platform: "ios", | ||
| token: deviceToken, // raw APNs hex token | ||
| userId: "user-42", | ||
| metadata: { app_version: "2.4.1" }, | ||
| }) | ||
| ``` | ||
| - Secure context (HTTPS) | ||
| - Service Worker support | ||
| - Push API support | ||
| - Notification API support | ||
| #### `np.devices.deactivate(id)` | ||
| ## Service Worker | ||
| Sets `status = inactive` on the device row. Subsequent sends skip it. | ||
| You need to provide a service worker at `/sw.js` in your public directory. Here's a minimal example: | ||
| ```ts | ||
| await np.devices.deactivate("device-id") | ||
| ``` | ||
| ```javascript | ||
| // sw.js | ||
| self.addEventListener('push', (event) => { | ||
| const data = event.data ? event.data.json() : {} | ||
| ### `subscribeWebPush(options)` — `nitroping/web` | ||
| const options = { | ||
| body: data.body || 'You have a new message', | ||
| icon: data.icon || '/favicon.ico', | ||
| badge: data.badge || '/favicon.ico', | ||
| data: data.data || {}, | ||
| tag: data.tag || 'default', | ||
| } | ||
| Browser-only. Registers a service worker, asks for permission, fetches the | ||
| VAPID public key, calls `pushManager.subscribe`, and registers the resulting | ||
| endpoint with nitroping — all in one call. | ||
| event.waitUntil( | ||
| self.registration.showNotification(data.title || 'Notification', options) | ||
| ) | ||
| ```ts | ||
| import { subscribeWebPush } from "nitroping/web" | ||
| const { device, subscription } = await subscribeWebPush({ | ||
| publicKey: "pk_live_...", // public, safe to ship in bundles | ||
| appId: "uuid-of-the-app", | ||
| serviceWorkerPath: "/sw.js", // optional, default shown | ||
| serviceWorkerScope: "/", // optional | ||
| userId: "user-42", // optional — enables { userIds: [...] } | ||
| }) | ||
| ``` | ||
| self.addEventListener('notificationclick', (event) => { | ||
| event.notification.close() | ||
| Idempotent — call on every page load; the server dedupes on | ||
| `(app_id, token)`. | ||
| event.waitUntil( | ||
| clients.matchAll({ type: 'window' }).then((clientList) => { | ||
| for (const client of clientList) { | ||
| if (client.url.includes(location.origin) && 'focus' in client) { | ||
| return client.focus() | ||
| } | ||
| } | ||
| if (clients.openWindow) { | ||
| return clients.openWindow('/') | ||
| } | ||
| }) | ||
| ) | ||
| ### `verifyWebhook(options)` — `nitroping/webhooks` | ||
| Verifies the `X-Nitroping-Signature` header and returns the parsed event. | ||
| ```ts | ||
| import { verifyWebhook } from "nitroping/webhooks" | ||
| const event = await verifyWebhook({ | ||
| body: rawString, | ||
| signature: request.headers.get("x-nitroping-signature"), | ||
| secret: process.env.NITROPING_WEBHOOK_SECRET!, | ||
| tolerance: 300, // optional, seconds. Default 300. | ||
| }) | ||
| ``` | ||
| ## Error Handling | ||
| The signing scheme is HMAC-SHA256 over `"<unix>.<raw body>"`. The header | ||
| ships as `t=<unix>, v1=<hex>` — same as Polar / Stripe. Use the raw | ||
| request body string (not a re-serialized parsed object) or the HMAC won't | ||
| match. | ||
| The SDK throws `NitroPingError` instances for various error conditions: | ||
| ## Framework recipes | ||
| ```typescript | ||
| import { NitroPingError } from 'nitroping' | ||
| ### Express / Fastify webhook handler | ||
| ```ts | ||
| import express from "express" | ||
| import { verifyWebhook } from "nitroping/webhooks" | ||
| const app = express() | ||
| app.post( | ||
| "/webhooks/nitroping", | ||
| express.raw({ type: "application/json" }), // keep the raw body | ||
| async (req, res) => { | ||
| try { | ||
| const event = await verifyWebhook({ | ||
| body: req.body.toString("utf8"), | ||
| signature: req.header("x-nitroping-signature"), | ||
| secret: process.env.NITROPING_WEBHOOK_SECRET!, | ||
| }) | ||
| // ...handle event... | ||
| res.status(200).send("ok") | ||
| } catch { | ||
| res.status(400).send("bad signature") | ||
| } | ||
| }, | ||
| ) | ||
| ``` | ||
| ### Hono / Cloudflare Workers | ||
| ```ts | ||
| import { Hono } from "hono" | ||
| import { Nitroping } from "nitroping" | ||
| import { verifyWebhook } from "nitroping/webhooks" | ||
| interface Env { | ||
| NITROPING_API_KEY: string | ||
| NITROPING_WEBHOOK_SECRET: string | ||
| } | ||
| const app = new Hono<{ Bindings: Env }>() | ||
| app.post("/send", async (c) => { | ||
| const np = new Nitroping({ apiKey: c.env.NITROPING_API_KEY }) | ||
| const result = await np.notifications.send({ | ||
| title: "Hello from Workers", | ||
| body: "Running on the edge", | ||
| target: { all: true }, | ||
| }) | ||
| return c.json(result) | ||
| }) | ||
| app.post("/webhooks", async (c) => { | ||
| const event = await verifyWebhook({ | ||
| body: await c.req.text(), | ||
| signature: c.req.header("x-nitroping-signature"), | ||
| secret: c.env.NITROPING_WEBHOOK_SECRET, | ||
| }) | ||
| return c.json({ received: event.id }) | ||
| }) | ||
| export default app | ||
| ``` | ||
| ### Next.js App Router | ||
| ```ts | ||
| // app/api/notify/route.ts | ||
| import { Nitroping } from "nitroping" | ||
| export const runtime = "edge" | ||
| export async function POST(request: Request) { | ||
| const np = new Nitroping({ apiKey: process.env.NITROPING_API_KEY! }) | ||
| const { title, body } = await request.json() | ||
| const result = await np.notifications.send({ | ||
| title, | ||
| body, | ||
| target: { all: true }, | ||
| }) | ||
| return Response.json(result) | ||
| } | ||
| ``` | ||
| ```ts | ||
| // app/api/webhooks/nitroping/route.ts | ||
| import { verifyWebhook } from "nitroping/webhooks" | ||
| export async function POST(request: Request) { | ||
| const body = await request.text() | ||
| const event = await verifyWebhook({ | ||
| body, | ||
| signature: request.headers.get("x-nitroping-signature"), | ||
| secret: process.env.NITROPING_WEBHOOK_SECRET!, | ||
| }) | ||
| // event.type: "notification.delivered" | "notification.failed" | | ||
| // "notification.opened" | "notification.clicked" | "webhook.test" | ||
| return Response.json({ ok: true, type: event.type }) | ||
| } | ||
| ``` | ||
| ## Errors | ||
| Every error thrown by the SDK extends `NitropingError`. Narrow by `instanceof` | ||
| to handle specific cases: | ||
| | Class | When it fires | | ||
| | -------------------------- | ----------------------------------------------------------------------------------------- | | ||
| | `NitropingError` | Base class. Any non-2xx response, or any internal failure with no more specific subclass. | | ||
| | `NetworkError` | `fetch` rejected (DNS, TLS, offline, abort). Original cause attached via `cause`. | | ||
| | `InvalidSignatureError` | `verifyWebhook` HMAC mismatch, missing header, malformed header. | | ||
| | `TimestampOutOfRangeError` | `verifyWebhook` signature valid but `t=` outside the tolerance window. | | ||
| | `WebPushUnsupportedError` | `subscribeWebPush` running where Service Worker / Push API isn't available. | | ||
| | `PermissionDeniedError` | `subscribeWebPush` and the user (or browser policy) blocks notifications. | | ||
| ```ts | ||
| import { Nitroping, NitropingError, NetworkError } from "nitroping" | ||
| try { | ||
| await client.subscribe() | ||
| } | ||
| catch (error) { | ||
| if (error instanceof NitroPingError) { | ||
| console.error('NitroPing error:', error.message, error.code) | ||
| await np.notifications.send({ title: "Hi", body: "There", target: { all: true } }) | ||
| } catch (err) { | ||
| if (err instanceof NetworkError) { | ||
| // transient — retry with backoff | ||
| } else if (err instanceof NitropingError && err.code === "quota_exceeded") { | ||
| // surface "upgrade your plan" UI | ||
| console.log(err.details) // { quota, used, resets_at } | ||
| } else { | ||
| throw err | ||
| } | ||
| else { | ||
| console.error('Unknown error:', error) | ||
| } | ||
| } | ||
| ``` | ||
| ## TypeScript | ||
| Type declarations ship in the package — no separate `@types/...` install | ||
| needed. The SDK targets `ESNext` with strict mode and avoids `any` in the | ||
| public surface. All public types (`SendNotificationRequest`, | ||
| `NotificationTarget`, `WebhookEvent`, etc.) are exported from the main | ||
| entry. | ||
| ## Runtime support | ||
| | Runtime | Status | | ||
| | ------------------- | -------------------------------------------- | | ||
| | Node 18 + | Yes | | ||
| | Bun 1.0 + | Yes | | ||
| | Deno 1.30 + | Yes | | ||
| | Cloudflare Workers | Yes | | ||
| | Vercel Edge Runtime | Yes | | ||
| | Modern browsers | Yes (`nitroping/web` + `nitroping/webhooks`) | | ||
| `nitroping` (the server SDK) is also usable in the browser, but you should | ||
| **not** ship the secret `np_` key — use `nitroping/web` with a public `pk_` | ||
| key instead. | ||
| ## License | ||
| MIT | ||
| [MIT](./LICENSE) — Copyright (c) 2026 productdevbook. | ||
| --- | ||
| <p align="center"> | ||
| <sub> | ||
| Built by <a href="https://github.com/productdevbook">@productdevbook</a> — <a href="https://nitroping.dev">nitroping.dev</a> · <a href="https://github.com/productdevbook/nitroping">OSS core</a> | ||
| </sub> | ||
| </p> |
| import { DeviceRegistration, IdentifyOptions, NitroPingConfig, PreferenceUpdateOptions, SubscriberPreferenceRecord, SubscriberProfile, SubscriptionOptions, SubscriptionStatus } from "./types.js"; | ||
| //#region src/client.d.ts | ||
| declare class NitroPingClient { | ||
| private config; | ||
| private storageKey; | ||
| constructor(config: NitroPingConfig); | ||
| /** | ||
| * Checks if push notifications are supported in the current environment | ||
| */ | ||
| isSupported(): boolean; | ||
| /** | ||
| * Gets the current notification permission status | ||
| */ | ||
| getPermissionStatus(): NotificationPermission; | ||
| /** | ||
| * Requests notification permission from the user | ||
| */ | ||
| requestPermission(): Promise<NotificationPermission>; | ||
| /** | ||
| * Subscribes to push notifications | ||
| */ | ||
| subscribe(options?: SubscriptionOptions): Promise<DeviceRegistration>; | ||
| /** | ||
| * Unsubscribes from push notifications and removes the device from the backend | ||
| */ | ||
| unsubscribe(): Promise<boolean>; | ||
| /** | ||
| * Checks if the user is currently subscribed | ||
| */ | ||
| isSubscribed(): Promise<boolean>; | ||
| /** | ||
| * Gets the current subscription status and details | ||
| */ | ||
| getSubscriptionStatus(): Promise<SubscriptionStatus>; | ||
| /** | ||
| * Identifies a subscriber (user) in the NitroPing system. | ||
| * If a subscriber with the given externalId already exists it is updated, | ||
| * otherwise a new subscriber is created. | ||
| * | ||
| * @example | ||
| * await client.identify('user-123', { name: 'John', email: 'user@example.com' }) | ||
| */ | ||
| identify(externalId: string, options?: IdentifyOptions): Promise<SubscriberProfile>; | ||
| /** | ||
| * Fetches a contact by external ID. | ||
| */ | ||
| getContact(externalId: string): Promise<SubscriberProfile | null>; | ||
| /** | ||
| * Fetches preferences for a contact by their internal ID. | ||
| */ | ||
| getPreferences(contactId: string): Promise<SubscriberPreferenceRecord[]>; | ||
| /** | ||
| * Tracks a notification event (delivered, opened, clicked). | ||
| */ | ||
| trackEvent(notificationId: string, event: 'delivered' | 'opened' | 'clicked'): Promise<void>; | ||
| /** | ||
| * Updates a subscriber's notification preference for a given category and channel. | ||
| * | ||
| * @example | ||
| * await client.updatePreference({ | ||
| * subscriberId: 'sub-id', | ||
| * category: 'marketing', | ||
| * channelType: 'EMAIL', | ||
| * enabled: false, | ||
| * }) | ||
| */ | ||
| updatePreference(input: PreferenceUpdateOptions & { | ||
| subscriberId: string; | ||
| }): Promise<SubscriberPreferenceRecord>; | ||
| /** | ||
| * Registers a device with the NitroPing backend | ||
| */ | ||
| private registerDevice; | ||
| /** | ||
| * Deletes a device from the backend | ||
| */ | ||
| private deleteDevice; | ||
| /** | ||
| * Gets or generates a user ID | ||
| */ | ||
| private getUserId; | ||
| /** | ||
| * Stores subscription data locally | ||
| */ | ||
| private storeSubscription; | ||
| /** | ||
| * Gets stored subscription data | ||
| */ | ||
| private getStoredSubscription; | ||
| /** | ||
| * Clears stored subscription data | ||
| */ | ||
| private clearSubscription; | ||
| } | ||
| //#endregion | ||
| export { NitroPingClient }; | ||
| //# sourceMappingURL=client.d.ts.map |
| {"version":3,"file":"client.d.ts","names":[],"sources":["../src/client.ts"],"mappings":";;;cA4Ba,eAAA;EAAA,QACH,MAAA;EAAA,QACA,UAAA;cAEI,MAAA,EAAQ,eAAA;;;;EAiBpB,WAAA,CAAA;EAc2B;;;EAP3B,mBAAA,CAAA,GAAuB,sBAAA;EAwGF;;;EAjGf,iBAAA,CAAA,GAAqB,OAAA,CAAQ,sBAAA;EA4KS;;;EA7JtC,SAAA,CAAU,OAAA,GAAS,mBAAA,GAA2B,OAAA,CAAQ,kBAAA;EA0MtB;;;EAxHhC,WAAA,CAAA,GAAe,OAAA;EAyNS;;;EApLxB,YAAA,CAAA,GAAgB,OAAA;EAoLoE;;;EAjKpF,qBAAA,CAAA,GAAyB,OAAA,CAAQ,kBAAA;EAxLnB;;;;;;;;EA2Md,QAAA,CAAS,UAAA,UAAoB,OAAA,GAAS,eAAA,GAAuB,OAAA,CAAQ,iBAAA;EA7JlD;;;EA0MnB,UAAA,CAAW,UAAA,WAAqB,OAAA,CAAQ,iBAAA;EAxHxC;;;EA2JA,cAAA,CAAe,SAAA,WAAoB,OAAA,CAAQ,0BAAA;EAnG3C;;;EAmIA,UAAA,CAAW,cAAA,UAAwB,KAAA,uCAA4C,OAAA;EAhHtE;;;;;;;;;;;EA8IT,gBAAA,CAAiB,KAAA,EAAO,uBAAA;IAA4B,YAAA;EAAA,IAAyB,OAAA,CAAQ,0BAAA;EA9B1E;;;EAAA,QAiEH,cAAA;EAnCgB;;;EAAA,QAqFhB,YAAA;EArF6E;;;EAAA,QA2GnF,SAAA;EAYA;;;EAAA,QAAA,iBAAA;EAsBiB;;;EAAA,QAXjB,qBAAA;;;;UAWA,iBAAA;AAAA"} |
| import{NitroPingError as e}from"./types.js";import{apiRequest as t,detectBrowser as n,detectOS as r,generateUserId as i,getBrowserVersion as a,getCategoryFromBrowser as o,getLocalStorage as s,getNotificationPermission as c,isPushSupported as l,isSecureContext as u,removeLocalStorage as d,setLocalStorage as f,urlBase64ToUint8Array as p}from"./utils.js";var m=class{constructor(t){if(this.config={apiUrl:`http://localhost:3000`,...t},this.storageKey=`nitroping_${this.config.appId}`,!this.config.appId)throw new e(`appId is required`,`INVALID_CONFIG`)}isSupported(){return l()&&u()}getPermissionStatus(){return c()}async requestPermission(){if(!this.isSupported())throw new e(`Push notifications are not supported in this environment`,`NOT_SUPPORTED`);return await Notification.requestPermission()}async subscribe(t={}){if(!this.isSupported())throw new e(`Push notifications are not supported in this environment`,`NOT_SUPPORTED`);if(!this.config.vapidPublicKey)throw new e(`vapidPublicKey is required for push subscriptions`,`INVALID_CONFIG`);if(await this.requestPermission()!==`granted`)throw new e(`Notification permission was denied`,`PERMISSION_DENIED`);let i=this.config.swPath??`/sw.js`,s=await navigator.serviceWorker.register(i);await navigator.serviceWorker.ready;let c=await s.pushManager.subscribe({userVisibleOnly:!0,applicationServerKey:p(this.config.vapidPublicKey)}),l={endpoint:c.endpoint,keys:{p256dh:btoa(String.fromCharCode(...new Uint8Array(c.getKey(`p256dh`)))),auth:btoa(String.fromCharCode(...new Uint8Array(c.getKey(`auth`))))}},u=t.userId||this.config.userId||this.getUserId(),d=n(),f=o(d),m=await this.registerDevice({appId:this.config.appId,token:c.endpoint,...f!==`WEB`&&{category:f},platform:`WEB`,userId:u,webPushP256dh:l.keys.p256dh,webPushAuth:l.keys.auth,metadata:JSON.stringify({userAgent:navigator.userAgent,browser:d,browserVersion:a(),os:r(),tags:t.tags||[],...t.metadata})});return this.storeSubscription({subscription:l,device:m,userId:u}),m}async unsubscribe(){try{let e=await(await navigator.serviceWorker.ready).pushManager.getSubscription(),t=this.getStoredSubscription();if(e&&await e.unsubscribe(),t?.device?.id)try{await this.deleteDevice(t.device.id)}catch{}return this.clearSubscription(),!0}catch(e){return console.error(`Failed to unsubscribe:`,e),!1}}async isSubscribed(){try{return this.isSupported()?await(await navigator.serviceWorker.ready).pushManager.getSubscription()!==null:!1}catch(e){return console.error(`Failed to check subscription status:`,e),!1}}async getSubscriptionStatus(){let e=await this.isSubscribed(),t=this.getStoredSubscription();return{isSubscribed:e,subscription:t?.subscription,device:t?.device}}async identify(n,r={}){let i=await t(`${this.config.apiUrl}/api/graphql`,{method:`POST`,body:JSON.stringify({query:` | ||
| mutation CreateContact($input: CreateContactInput!) { | ||
| createContact(input: $input) { | ||
| id | ||
| appId | ||
| externalId | ||
| name | ||
| phone | ||
| locale | ||
| metadata | ||
| createdAt | ||
| updatedAt | ||
| } | ||
| } | ||
| `,variables:{input:{appId:this.config.appId,externalId:n,...r}}})});if(!i.data?.createContact)throw new e(`Failed to identify subscriber`,`IDENTIFY_FAILED`);return i.data.createContact}async getContact(e){return(await t(`${this.config.apiUrl}/api/graphql`,{method:`POST`,body:JSON.stringify({query:` | ||
| query GetContact($appId: ID!, $externalId: String!) { | ||
| contactByExternalId(appId: $appId, externalId: $externalId) { | ||
| id | ||
| appId | ||
| externalId | ||
| name | ||
| phone | ||
| locale | ||
| metadata | ||
| createdAt | ||
| updatedAt | ||
| } | ||
| } | ||
| `,variables:{appId:this.config.appId,externalId:e}})})).data?.contactByExternalId??null}async getPreferences(e){return(await t(`${this.config.apiUrl}/api/graphql`,{method:`POST`,body:JSON.stringify({query:` | ||
| query GetContact($id: ID!) { | ||
| contact(id: $id) { | ||
| preferences { | ||
| id | ||
| subscriberId | ||
| category | ||
| channelType | ||
| enabled | ||
| updatedAt | ||
| } | ||
| } | ||
| } | ||
| `,variables:{id:e}})})).data?.contact?.preferences??[]}async trackEvent(e,n){let r={delivered:`mutation { trackNotificationDelivered(notificationId: "${e}") }`,opened:`mutation { trackNotificationOpened(notificationId: "${e}") }`,clicked:`mutation { trackNotificationClicked(notificationId: "${e}") }`}[n];r&&await t(`${this.config.apiUrl}/api/graphql`,{method:`POST`,body:JSON.stringify({query:r})})}async updatePreference(n){let r=await t(`${this.config.apiUrl}/api/graphql`,{method:`POST`,body:JSON.stringify({query:` | ||
| mutation UpdateContactPreference($input: UpdateContactPreferenceInput!) { | ||
| updateContactPreference(input: $input) { | ||
| id | ||
| subscriberId | ||
| category | ||
| channelType | ||
| enabled | ||
| updatedAt | ||
| } | ||
| } | ||
| `,variables:{input:n}})});if(!r.data?.updateContactPreference)throw new e(`Failed to update preference`,`PREFERENCE_UPDATE_FAILED`);return r.data.updateContactPreference}async registerDevice(n){let r=await t(`${this.config.apiUrl}/api/graphql`,{method:`POST`,body:JSON.stringify({query:` | ||
| mutation RegisterDevice($input: RegisterDeviceInput!) { | ||
| registerDevice(input: $input) { | ||
| id | ||
| appId | ||
| token | ||
| platform | ||
| userId | ||
| webPushP256dh | ||
| webPushAuth | ||
| metadata | ||
| status | ||
| lastSeenAt | ||
| createdAt | ||
| updatedAt | ||
| } | ||
| } | ||
| `,variables:{input:n}})});if(!r.data?.registerDevice)throw new e(`Failed to register device`,`REGISTRATION_FAILED`);return r.data.registerDevice}async deleteDevice(e){await t(`${this.config.apiUrl}/api/graphql`,{method:`POST`,body:JSON.stringify({query:` | ||
| mutation DeleteDevice($id: ID!) { | ||
| deleteDevice(id: $id) | ||
| } | ||
| `,variables:{id:e}})})}getUserId(){let e=s(`${this.storageKey}_userId`);return e||(e=i(),f(`${this.storageKey}_userId`,e)),e}storeSubscription(e){f(this.storageKey,e)}getStoredSubscription(){return s(this.storageKey)}clearSubscription(){d(this.storageKey)}};export{m as NitroPingClient}; | ||
| //# sourceMappingURL=client.js.map |
| {"version":3,"file":"client.js","names":[],"sources":["../src/client.ts"],"sourcesContent":["import type {\n DeviceRegistration,\n IdentifyOptions,\n NitroPingConfig,\n PreferenceUpdateOptions,\n PushSubscriptionData,\n SubscriberPreferenceRecord,\n SubscriberProfile,\n SubscriptionOptions,\n SubscriptionStatus,\n} from './types.ts'\nimport { NitroPingError } from './types.ts'\nimport {\n apiRequest,\n detectBrowser,\n detectOS,\n generateUserId,\n getBrowserVersion,\n getCategoryFromBrowser,\n getLocalStorage,\n getNotificationPermission,\n isPushSupported,\n isSecureContext,\n removeLocalStorage,\n setLocalStorage,\n urlBase64ToUint8Array,\n} from './utils.ts'\n\nexport class NitroPingClient {\n private config: NitroPingConfig\n private storageKey: string\n\n constructor(config: NitroPingConfig) {\n this.config = {\n apiUrl: 'http://localhost:3000',\n ...config,\n }\n this.storageKey = `nitroping_${this.config.appId}`\n\n // Validate required config\n if (!this.config.appId) {\n throw new NitroPingError('appId is required', 'INVALID_CONFIG')\n }\n // vapidPublicKey is only required when subscribe() is called, not at construction\n }\n\n /**\n * Checks if push notifications are supported in the current environment\n */\n isSupported(): boolean {\n return isPushSupported() && isSecureContext()\n }\n\n /**\n * Gets the current notification permission status\n */\n getPermissionStatus(): NotificationPermission {\n return getNotificationPermission()\n }\n\n /**\n * Requests notification permission from the user\n */\n async requestPermission(): Promise<NotificationPermission> {\n if (!this.isSupported()) {\n throw new NitroPingError(\n 'Push notifications are not supported in this environment',\n 'NOT_SUPPORTED',\n )\n }\n\n const permission = await Notification.requestPermission()\n return permission\n }\n\n /**\n * Subscribes to push notifications\n */\n async subscribe(options: SubscriptionOptions = {}): Promise<DeviceRegistration> {\n if (!this.isSupported()) {\n throw new NitroPingError(\n 'Push notifications are not supported in this environment',\n 'NOT_SUPPORTED',\n )\n }\n\n // vapidPublicKey is required for push subscriptions\n if (!this.config.vapidPublicKey) {\n throw new NitroPingError('vapidPublicKey is required for push subscriptions', 'INVALID_CONFIG')\n }\n\n // Request permission if not already granted\n const permission = await this.requestPermission()\n if (permission !== 'granted') {\n throw new NitroPingError(\n 'Notification permission was denied',\n 'PERMISSION_DENIED',\n )\n }\n\n // Register service worker\n const swPath = this.config.swPath ?? '/sw.js'\n const registration = await navigator.serviceWorker.register(swPath)\n await navigator.serviceWorker.ready\n\n // Subscribe to push notifications\n const subscription = await registration.pushManager.subscribe({\n userVisibleOnly: true,\n applicationServerKey: urlBase64ToUint8Array(this.config.vapidPublicKey),\n })\n\n // Prepare subscription data\n const subscriptionData: PushSubscriptionData = {\n endpoint: subscription.endpoint,\n keys: {\n p256dh: btoa(String.fromCharCode(...new Uint8Array(subscription.getKey('p256dh')!))),\n auth: btoa(String.fromCharCode(...new Uint8Array(subscription.getKey('auth')!))),\n },\n }\n\n // Get or generate user ID\n const userId = options.userId || this.config.userId || this.getUserId()\n\n // Register device with backend\n const browserType = detectBrowser()\n const category = getCategoryFromBrowser(browserType)\n\n const device = await this.registerDevice({\n appId: this.config.appId,\n token: subscription.endpoint,\n // Only set category for known browsers, otherwise null\n ...(category !== 'WEB' && { category }),\n platform: 'WEB',\n userId,\n // WebPush subscription keys for encryption\n webPushP256dh: subscriptionData.keys.p256dh,\n webPushAuth: subscriptionData.keys.auth,\n metadata: JSON.stringify({\n userAgent: navigator.userAgent,\n browser: browserType,\n browserVersion: getBrowserVersion(),\n os: detectOS(),\n tags: options.tags || [],\n ...options.metadata,\n }),\n })\n\n // Store subscription locally\n this.storeSubscription({\n subscription: subscriptionData,\n device,\n userId,\n })\n\n return device\n }\n\n /**\n * Unsubscribes from push notifications and removes the device from the backend\n */\n async unsubscribe(): Promise<boolean> {\n try {\n // Get current subscription\n const registration = await navigator.serviceWorker.ready\n const subscription = await registration.pushManager.getSubscription()\n\n const stored = this.getStoredSubscription()\n\n if (subscription) {\n // Unsubscribe from push manager\n await subscription.unsubscribe()\n }\n\n // Delete device from backend if we have a device ID\n if (stored?.device?.id) {\n try {\n await this.deleteDevice(stored.device.id)\n }\n catch {\n // Best-effort — do not fail unsubscribe if backend call fails\n }\n }\n\n // Remove from local storage\n this.clearSubscription()\n\n return true\n }\n catch (error) {\n console.error('Failed to unsubscribe:', error)\n return false\n }\n }\n\n /**\n * Checks if the user is currently subscribed\n */\n async isSubscribed(): Promise<boolean> {\n try {\n if (!this.isSupported())\n return false\n\n const registration = await navigator.serviceWorker.ready\n const subscription = await registration.pushManager.getSubscription()\n\n return subscription !== null\n }\n catch (error) {\n console.error('Failed to check subscription status:', error)\n return false\n }\n }\n\n /**\n * Gets the current subscription status and details\n */\n async getSubscriptionStatus(): Promise<SubscriptionStatus> {\n const isSubscribed = await this.isSubscribed()\n const stored = this.getStoredSubscription()\n\n return {\n isSubscribed,\n subscription: stored?.subscription,\n device: stored?.device,\n }\n }\n\n /**\n * Identifies a subscriber (user) in the NitroPing system.\n * If a subscriber with the given externalId already exists it is updated,\n * otherwise a new subscriber is created.\n *\n * @example\n * await client.identify('user-123', { name: 'John', email: 'user@example.com' })\n */\n async identify(externalId: string, options: IdentifyOptions = {}): Promise<SubscriberProfile> {\n const query = `\n mutation CreateContact($input: CreateContactInput!) {\n createContact(input: $input) {\n id\n appId\n externalId\n name\n email\n phone\n locale\n metadata\n createdAt\n updatedAt\n }\n }\n `\n\n const response = await apiRequest<{ data: { createContact: SubscriberProfile } }>(\n `${this.config.apiUrl}/api/graphql`,\n {\n method: 'POST',\n body: JSON.stringify({\n query,\n variables: {\n input: {\n appId: this.config.appId,\n externalId,\n ...options,\n },\n },\n }),\n },\n )\n\n if (!response.data?.createContact) {\n throw new NitroPingError('Failed to identify subscriber', 'IDENTIFY_FAILED')\n }\n\n return response.data.createContact\n }\n\n /**\n * Fetches a contact by external ID.\n */\n async getContact(externalId: string): Promise<SubscriberProfile | null> {\n const query = `\n query GetContact($appId: ID!, $externalId: String!) {\n contactByExternalId(appId: $appId, externalId: $externalId) {\n id\n appId\n externalId\n name\n email\n phone\n locale\n metadata\n createdAt\n updatedAt\n }\n }\n `\n\n const response = await apiRequest<{ data: { contactByExternalId: SubscriberProfile | null } }>(\n `${this.config.apiUrl}/api/graphql`,\n {\n method: 'POST',\n body: JSON.stringify({\n query,\n variables: { appId: this.config.appId, externalId },\n }),\n },\n )\n\n return response.data?.contactByExternalId ?? null\n }\n\n /**\n * Fetches preferences for a contact by their internal ID.\n */\n async getPreferences(contactId: string): Promise<SubscriberPreferenceRecord[]> {\n const query = `\n query GetContact($id: ID!) {\n contact(id: $id) {\n preferences {\n id\n subscriberId\n category\n channelType\n enabled\n updatedAt\n }\n }\n }\n `\n\n const response = await apiRequest<{\n data: { contact: { preferences: SubscriberPreferenceRecord[] } | null }\n }>(\n `${this.config.apiUrl}/api/graphql`,\n {\n method: 'POST',\n body: JSON.stringify({ query, variables: { id: contactId } }),\n },\n )\n\n return response.data?.contact?.preferences ?? []\n }\n\n /**\n * Tracks a notification event (delivered, opened, clicked).\n */\n async trackEvent(notificationId: string, event: 'delivered' | 'opened' | 'clicked'): Promise<void> {\n const mutationMap: Record<string, string> = {\n delivered: `mutation { trackNotificationDelivered(notificationId: \"${notificationId}\") }`,\n opened: `mutation { trackNotificationOpened(notificationId: \"${notificationId}\") }`,\n clicked: `mutation { trackNotificationClicked(notificationId: \"${notificationId}\") }`,\n }\n\n const query = mutationMap[event]\n if (!query) return\n\n await apiRequest(\n `${this.config.apiUrl}/api/graphql`,\n {\n method: 'POST',\n body: JSON.stringify({ query }),\n },\n )\n }\n\n /**\n * Updates a subscriber's notification preference for a given category and channel.\n *\n * @example\n * await client.updatePreference({\n * subscriberId: 'sub-id',\n * category: 'marketing',\n * channelType: 'EMAIL',\n * enabled: false,\n * })\n */\n async updatePreference(input: PreferenceUpdateOptions & { subscriberId: string }): Promise<SubscriberPreferenceRecord> {\n const query = `\n mutation UpdateContactPreference($input: UpdateContactPreferenceInput!) {\n updateContactPreference(input: $input) {\n id\n subscriberId\n category\n channelType\n enabled\n updatedAt\n }\n }\n `\n\n const response = await apiRequest<{ data: { updateContactPreference: SubscriberPreferenceRecord } }>(\n `${this.config.apiUrl}/api/graphql`,\n {\n method: 'POST',\n body: JSON.stringify({\n query,\n variables: { input },\n }),\n },\n )\n\n if (!response.data?.updateContactPreference) {\n throw new NitroPingError('Failed to update preference', 'PREFERENCE_UPDATE_FAILED')\n }\n\n return response.data.updateContactPreference\n }\n\n /**\n * Registers a device with the NitroPing backend\n */\n private async registerDevice(input: {\n appId: string\n token: string\n category?: string\n platform: string\n userId: string\n webPushP256dh: string\n webPushAuth: string\n metadata: string\n }): Promise<DeviceRegistration> {\n const query = `\n mutation RegisterDevice($input: RegisterDeviceInput!) {\n registerDevice(input: $input) {\n id\n appId\n token\n platform\n userId\n webPushP256dh\n webPushAuth\n metadata\n status\n lastSeenAt\n createdAt\n updatedAt\n }\n }\n `\n\n const response = await apiRequest<{ data: { registerDevice: DeviceRegistration } }>(\n `${this.config.apiUrl}/api/graphql`,\n {\n method: 'POST',\n body: JSON.stringify({\n query,\n variables: { input },\n }),\n },\n )\n\n if (!response.data?.registerDevice) {\n throw new NitroPingError('Failed to register device', 'REGISTRATION_FAILED')\n }\n\n return response.data.registerDevice\n }\n\n /**\n * Deletes a device from the backend\n */\n private async deleteDevice(deviceId: string): Promise<void> {\n const query = `\n mutation DeleteDevice($id: ID!) {\n deleteDevice(id: $id)\n }\n `\n\n await apiRequest(\n `${this.config.apiUrl}/api/graphql`,\n {\n method: 'POST',\n body: JSON.stringify({\n query,\n variables: { id: deviceId },\n }),\n },\n )\n }\n\n /**\n * Gets or generates a user ID\n */\n private getUserId(): string {\n let userId = getLocalStorage<string>(`${this.storageKey}_userId`)\n if (!userId) {\n userId = generateUserId()\n setLocalStorage(`${this.storageKey}_userId`, userId)\n }\n return userId\n }\n\n /**\n * Stores subscription data locally\n */\n private storeSubscription(data: {\n subscription: PushSubscriptionData\n device: DeviceRegistration\n userId: string\n }): void {\n setLocalStorage(this.storageKey, data)\n }\n\n /**\n * Gets stored subscription data\n */\n private getStoredSubscription(): {\n subscription: PushSubscriptionData\n device: DeviceRegistration\n userId: string\n } | null {\n return getLocalStorage(this.storageKey)\n }\n\n /**\n * Clears stored subscription data\n */\n private clearSubscription(): void {\n removeLocalStorage(this.storageKey)\n }\n}\n"],"mappings":"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"} |
| import { APIResponse, DeviceRegistration, IdentifyOptions, NitroPingConfig, NitroPingError, NotificationAction, NotificationPayload, PreferenceUpdateOptions, PushSubscriptionData, SubscriberPreferenceRecord, SubscriberProfile, SubscriptionOptions, SubscriptionStatus } from "./types.js"; | ||
| import { NitroPingClient } from "./client.js"; | ||
| import { detectBrowser, detectOS, generateUserId, getBrowserVersion, getNotificationPermission, isPushSupported, isSecureContext, urlBase64ToUint8Array } from "./utils.js"; | ||
| export { type APIResponse, type DeviceRegistration, type IdentifyOptions, NitroPingClient, type NitroPingConfig, NitroPingError, type NotificationAction, type NotificationPayload, type PreferenceUpdateOptions, type PushSubscriptionData, type SubscriberPreferenceRecord, type SubscriberProfile, type SubscriptionOptions, type SubscriptionStatus, detectBrowser, detectOS, generateUserId, getBrowserVersion, getNotificationPermission, isPushSupported, isSecureContext, urlBase64ToUint8Array }; |
| import{NitroPingError as e}from"./types.js";import{detectBrowser as t,detectOS as n,generateUserId as r,getBrowserVersion as i,getNotificationPermission as a,isPushSupported as o,isSecureContext as s,urlBase64ToUint8Array as c}from"./utils.js";import{NitroPingClient as l}from"./client.js";export{l as NitroPingClient,e as NitroPingError,t as detectBrowser,n as detectOS,r as generateUserId,i as getBrowserVersion,a as getNotificationPermission,o as isPushSupported,s as isSecureContext,c as urlBase64ToUint8Array}; |
-105
| //#region src/types.d.ts | ||
| interface NitroPingConfig { | ||
| appId: string; | ||
| vapidPublicKey?: string; | ||
| apiUrl?: string; | ||
| userId?: string; | ||
| swPath?: string; | ||
| } | ||
| interface SubscriberProfile { | ||
| id: string; | ||
| appId: string; | ||
| externalId: string; | ||
| name?: string; | ||
| email?: string; | ||
| phone?: string; | ||
| locale?: string; | ||
| metadata?: Record<string, any>; | ||
| createdAt: string; | ||
| updatedAt: string; | ||
| } | ||
| interface IdentifyOptions { | ||
| name?: string; | ||
| email?: string; | ||
| phone?: string; | ||
| locale?: string; | ||
| metadata?: Record<string, any>; | ||
| } | ||
| interface PreferenceUpdateOptions { | ||
| category: string; | ||
| channelType: 'PUSH' | 'EMAIL' | 'SMS' | 'IN_APP' | 'DISCORD'; | ||
| enabled: boolean; | ||
| } | ||
| interface SubscriberPreferenceRecord { | ||
| id: string; | ||
| subscriberId: string; | ||
| category: string; | ||
| channelType: string; | ||
| enabled: boolean; | ||
| updatedAt: string; | ||
| } | ||
| interface SubscriptionOptions { | ||
| userId?: string; | ||
| tags?: string[]; | ||
| metadata?: Record<string, any>; | ||
| } | ||
| interface PushSubscriptionData { | ||
| endpoint: string; | ||
| keys: { | ||
| p256dh: string; | ||
| auth: string; | ||
| }; | ||
| } | ||
| interface DeviceRegistration { | ||
| id: string; | ||
| appId: string; | ||
| token: string; | ||
| platform: 'WEB'; | ||
| userId?: string; | ||
| webPushP256dh?: string; | ||
| webPushAuth?: string; | ||
| metadata?: string; | ||
| status: 'ACTIVE' | 'INACTIVE'; | ||
| lastSeenAt: string; | ||
| createdAt: string; | ||
| updatedAt: string; | ||
| } | ||
| interface NotificationPayload { | ||
| title: string; | ||
| body: string; | ||
| icon?: string; | ||
| image?: string; | ||
| badge?: string; | ||
| tag?: string; | ||
| data?: any; | ||
| actions?: NotificationAction[]; | ||
| silent?: boolean; | ||
| renotify?: boolean; | ||
| requireInteraction?: boolean; | ||
| timestamp?: number; | ||
| vibrate?: number[]; | ||
| sound?: string; | ||
| } | ||
| interface NotificationAction { | ||
| action: string; | ||
| title: string; | ||
| icon?: string; | ||
| } | ||
| interface APIResponse<T = any> { | ||
| data?: T; | ||
| error?: string; | ||
| statusCode?: number; | ||
| } | ||
| interface SubscriptionStatus { | ||
| isSubscribed: boolean; | ||
| subscription?: PushSubscriptionData; | ||
| device?: DeviceRegistration; | ||
| } | ||
| declare class NitroPingError extends Error { | ||
| code?: string | undefined; | ||
| statusCode?: number | undefined; | ||
| constructor(message: string, code?: string | undefined, statusCode?: number | undefined); | ||
| } | ||
| //#endregion | ||
| export { APIResponse, DeviceRegistration, IdentifyOptions, NitroPingConfig, NitroPingError, NotificationAction, NotificationPayload, PreferenceUpdateOptions, PushSubscriptionData, SubscriberPreferenceRecord, SubscriberProfile, SubscriptionOptions, SubscriptionStatus }; | ||
| //# sourceMappingURL=types.d.ts.map |
| {"version":3,"file":"types.d.ts","names":[],"sources":["../src/types.ts"],"mappings":";UAAiB,eAAA;EACf,KAAA;EACA,cAAA;EACA,MAAA;EACA,MAAA;EACA,MAAA;AAAA;AAAA,UAKe,iBAAA;EACf,EAAA;EACA,KAAA;EACA,UAAA;EACA,IAAA;EACA,KAAA;EACA,KAAA;EACA,MAAA;EACA,QAAA,GAAW,MAAA;EACX,SAAA;EACA,SAAA;AAAA;AAAA,UAGe,eAAA;EACf,IAAA;EACA,KAAA;EACA,KAAA;EACA,MAAA;EACA,QAAA,GAAW,MAAA;AAAA;AAAA,UAGI,uBAAA;EACf,QAAA;EACA,WAAA;EACA,OAAA;AAAA;AAAA,UAGe,0BAAA;EACf,EAAA;EACA,YAAA;EACA,QAAA;EACA,WAAA;EACA,OAAA;EACA,SAAA;AAAA;AAAA,UAGe,mBAAA;EACf,MAAA;EACA,IAAA;EACA,QAAA,GAAW,MAAA;AAAA;AAAA,UAGI,oBAAA;EACf,QAAA;EACA,IAAA;IACE,MAAA;IACA,IAAA;EAAA;AAAA;AAAA,UAIa,kBAAA;EACf,EAAA;EACA,KAAA;EACA,KAAA;EACA,QAAA;EACA,MAAA;EACA,aAAA;EACA,WAAA;EACA,QAAA;EACA,MAAA;EACA,UAAA;EACA,SAAA;EACA,SAAA;AAAA;AAAA,UAGe,mBAAA;EACf,KAAA;EACA,IAAA;EACA,IAAA;EACA,KAAA;EACA,KAAA;EACA,GAAA;EACA,IAAA;EACA,OAAA,GAAU,kBAAA;EACV,MAAA;EACA,QAAA;EACA,kBAAA;EACA,SAAA;EACA,OAAA;EACA,KAAA;AAAA;AAAA,UAGe,kBAAA;EACf,MAAA;EACA,KAAA;EACA,IAAA;AAAA;AAAA,UAGe,WAAA;EACf,IAAA,GAAO,CAAA;EACP,KAAA;EACA,UAAA;AAAA;AAAA,UAGe,kBAAA;EACf,YAAA;EACA,YAAA,GAAe,oBAAA;EACf,MAAA,GAAS,kBAAA;AAAA;AAAA,cAGE,cAAA,SAAuB,KAAA;EAGzB,IAAA;EACA,UAAA;cAFP,OAAA,UACO,IAAA,uBACA,UAAA;AAAA"} |
| var e=class extends Error{constructor(e,t,n){super(e),this.code=t,this.statusCode=n,this.name=`NitroPingError`}};export{e as NitroPingError}; | ||
| //# sourceMappingURL=types.js.map |
| {"version":3,"file":"types.js","names":[],"sources":["../src/types.ts"],"sourcesContent":["export interface NitroPingConfig {\n appId: string\n vapidPublicKey?: string // required for push subscriptions, optional for email/sms-only usage\n apiUrl?: string\n userId?: string\n swPath?: string // custom service worker path, default: '/sw.js'\n}\n\n// ── Subscriber / multi-channel types ─────────────────────────────────────────\n\nexport interface SubscriberProfile {\n id: string\n appId: string\n externalId: string\n name?: string\n email?: string\n phone?: string\n locale?: string\n metadata?: Record<string, any>\n createdAt: string\n updatedAt: string\n}\n\nexport interface IdentifyOptions {\n name?: string\n email?: string\n phone?: string\n locale?: string\n metadata?: Record<string, any>\n}\n\nexport interface PreferenceUpdateOptions {\n category: string\n channelType: 'PUSH' | 'EMAIL' | 'SMS' | 'IN_APP' | 'DISCORD'\n enabled: boolean\n}\n\nexport interface SubscriberPreferenceRecord {\n id: string\n subscriberId: string\n category: string\n channelType: string\n enabled: boolean\n updatedAt: string\n}\n\nexport interface SubscriptionOptions {\n userId?: string\n tags?: string[]\n metadata?: Record<string, any>\n}\n\nexport interface PushSubscriptionData {\n endpoint: string\n keys: {\n p256dh: string\n auth: string\n }\n}\n\nexport interface DeviceRegistration {\n id: string\n appId: string\n token: string\n platform: 'WEB'\n userId?: string\n webPushP256dh?: string\n webPushAuth?: string\n metadata?: string\n status: 'ACTIVE' | 'INACTIVE'\n lastSeenAt: string\n createdAt: string\n updatedAt: string\n}\n\nexport interface NotificationPayload {\n title: string\n body: string\n icon?: string\n image?: string\n badge?: string\n tag?: string\n data?: any\n actions?: NotificationAction[]\n silent?: boolean\n renotify?: boolean\n requireInteraction?: boolean\n timestamp?: number\n vibrate?: number[]\n sound?: string\n}\n\nexport interface NotificationAction {\n action: string\n title: string\n icon?: string\n}\n\nexport interface APIResponse<T = any> {\n data?: T\n error?: string\n statusCode?: number\n}\n\nexport interface SubscriptionStatus {\n isSubscribed: boolean\n subscription?: PushSubscriptionData\n device?: DeviceRegistration\n}\n\nexport class NitroPingError extends Error {\n constructor(\n message: string,\n public code?: string,\n public statusCode?: number,\n ) {\n super(message)\n this.name = 'NitroPingError'\n }\n}\n"],"mappings":"AA8GA,IAAa,EAAb,cAAoC,KAAM,CACxC,YACE,EACA,EACA,EACA,CACA,MAAM,EAAQ,CAHP,KAAA,KAAA,EACA,KAAA,WAAA,EAGP,KAAK,KAAO"} |
| //#region src/utils.d.ts | ||
| /** | ||
| * Converts a VAPID public key from base64url format to Uint8Array for use with the Push API | ||
| */ | ||
| declare function urlBase64ToUint8Array(base64String: string): Uint8Array; | ||
| /** | ||
| * Generates a random user ID for anonymous users | ||
| */ | ||
| declare function generateUserId(): string; | ||
| /** | ||
| * Checks if the current environment supports push notifications | ||
| */ | ||
| declare function isPushSupported(): boolean; | ||
| /** | ||
| * Checks if we're running in a secure context (required for push notifications) | ||
| */ | ||
| declare function isSecureContext(): boolean; | ||
| /** | ||
| * Gets the current notification permission status | ||
| */ | ||
| declare function getNotificationPermission(): NotificationPermission; | ||
| /** | ||
| * Detects the browser type from user agent | ||
| */ | ||
| declare function detectBrowser(): string; | ||
| /** | ||
| * Detects the operating system from user agent | ||
| */ | ||
| declare function detectOS(): string; | ||
| /** | ||
| * Gets browser version from user agent | ||
| */ | ||
| declare function getBrowserVersion(): string; | ||
| //#endregion | ||
| export { detectBrowser, detectOS, generateUserId, getBrowserVersion, getNotificationPermission, isPushSupported, isSecureContext, urlBase64ToUint8Array }; | ||
| //# sourceMappingURL=utils.d.ts.map |
| {"version":3,"file":"utils.d.ts","names":[],"sources":["../src/utils.ts"],"mappings":";;AAKA;;iBAAgB,qBAAA,CAAsB,YAAA,WAAuB,UAAA;;;AAmB7D;iBAAgB,cAAA,CAAA;;;;iBAOA,eAAA,CAAA;;;;iBAYA,eAAA,CAAA;AAAhB;;;AAAA,iBAOgB,yBAAA,CAAA,GAA6B,sBAAA;AA4I7C;;;AAAA,iBAlDgB,aAAA,CAAA;;;;iBAyBA,QAAA,CAAA;;;;iBAyBA,iBAAA,CAAA"} |
| import{NitroPingError as e}from"./types.js";function t(e){let t=(e+`=`.repeat((4-e.length%4)%4)).replace(/-/g,`+`).replace(/_/g,`/`),n=globalThis.atob(t),r=new Uint8Array(n.length);for(let e=0;e<n.length;++e)r[e]=n.charCodeAt(e);return r}function n(){return`user-${Math.random().toString(36).substring(2,15)}-${Date.now()}`}function r(){return typeof globalThis<`u`&&`serviceWorker`in navigator&&`PushManager`in globalThis&&`Notification`in globalThis}function i(){return globalThis.isSecureContext===!0}function a(){return Notification.permission}async function o(t,n={}){let r=await fetch(t,{headers:{"Content-Type":`application/json`,...n.headers},...n});if(!r.ok){let t=await r.text(),n=`HTTP ${r.status}: ${r.statusText}`;try{n=JSON.parse(t).message||n}catch{t&&(n=t)}throw new e(n,`API_ERROR`,r.status)}let i=r.headers.get(`content-type`);return i&&i.includes(`application/json`)?r.json():r.text()}function s(e,t){try{globalThis.localStorage.setItem(e,JSON.stringify(t))}catch(e){console.warn(`Failed to save to localStorage:`,e)}}function c(e){try{let t=globalThis.localStorage.getItem(e);return t?JSON.parse(t):null}catch(e){return console.warn(`Failed to read from localStorage:`,e),null}}function l(e){try{globalThis.localStorage.removeItem(e)}catch(e){console.warn(`Failed to remove from localStorage:`,e)}}function u(){let e=navigator.userAgent.toLowerCase();return e.includes(`chrome`)&&!e.includes(`edg`)?`chrome`:e.includes(`firefox`)?`firefox`:e.includes(`safari`)&&!e.includes(`chrome`)?`safari`:e.includes(`edg`)?`edge`:e.includes(`opera`)?`opera`:`unknown`}function d(){let e=navigator.userAgent.toLowerCase();return e.includes(`mac`)?`mac`:e.includes(`win`)?`windows`:e.includes(`linux`)?`linux`:e.includes(`android`)?`android`:e.includes(`iphone`)||e.includes(`ipad`)?`ios`:`unknown`}function f(){let e=navigator.userAgent,t=u();try{let n=null;switch(t){case`chrome`:n=e.match(/Chrome\/(\d+\.\d+\.\d+\.\d+)/);break;case`firefox`:n=e.match(/Firefox\/(\d+\.\d+)/);break;case`safari`:n=e.match(/Version\/(\d+\.\d+)/);break;case`edge`:n=e.match(/Edg\/(\d+\.\d+\.\d+\.\d+)/);break;case`opera`:n=e.match(/OPR\/(\d+\.\d+\.\d+\.\d+)/);break}return n?n[1]:`unknown`}catch{return`unknown`}}function p(e){switch(e.toLowerCase()){case`chrome`:return`CHROME`;case`firefox`:return`FIREFOX`;case`safari`:return`SAFARI`;case`edge`:return`EDGE`;case`opera`:return`OPERA`;default:return`WEB`}}export{o as apiRequest,u as detectBrowser,d as detectOS,n as generateUserId,f as getBrowserVersion,p as getCategoryFromBrowser,c as getLocalStorage,a as getNotificationPermission,r as isPushSupported,i as isSecureContext,l as removeLocalStorage,s as setLocalStorage,t as urlBase64ToUint8Array}; | ||
| //# sourceMappingURL=utils.js.map |
| {"version":3,"file":"utils.js","names":[],"sources":["../src/utils.ts"],"sourcesContent":["import { NitroPingError } from './types.ts'\n\n/**\n * Converts a VAPID public key from base64url format to Uint8Array for use with the Push API\n */\nexport function urlBase64ToUint8Array(base64String: string): Uint8Array {\n const padding = '='.repeat((4 - base64String.length % 4) % 4)\n const base64 = (base64String + padding)\n .replace(/-/g, '+')\n .replace(/_/g, '/')\n\n const rawData = globalThis.atob(base64)\n const outputArray = new Uint8Array(rawData.length)\n\n for (let i = 0; i < rawData.length; ++i) {\n outputArray[i] = rawData.charCodeAt(i)\n }\n\n return outputArray\n}\n\n/**\n * Generates a random user ID for anonymous users\n */\nexport function generateUserId(): string {\n return `user-${Math.random().toString(36).substring(2, 15)}-${Date.now()}`\n}\n\n/**\n * Checks if the current environment supports push notifications\n */\nexport function isPushSupported(): boolean {\n return (\n typeof globalThis !== 'undefined'\n && 'serviceWorker' in navigator\n && 'PushManager' in globalThis\n && 'Notification' in globalThis\n )\n}\n\n/**\n * Checks if we're running in a secure context (required for push notifications)\n */\nexport function isSecureContext(): boolean {\n return globalThis.isSecureContext === true\n}\n\n/**\n * Gets the current notification permission status\n */\nexport function getNotificationPermission(): NotificationPermission {\n return Notification.permission\n}\n\n/**\n * Makes an HTTP request to the NitroPing API\n */\nexport async function apiRequest<T = any>(\n url: string,\n options: RequestInit = {},\n): Promise<T> {\n const response = await fetch(url, {\n headers: {\n 'Content-Type': 'application/json',\n ...options.headers,\n },\n ...options,\n })\n\n if (!response.ok) {\n const errorText = await response.text()\n let errorMessage = `HTTP ${response.status}: ${response.statusText}`\n\n try {\n const errorData = JSON.parse(errorText)\n errorMessage = errorData.message || errorMessage\n }\n catch {\n // If it's not JSON, use the raw text\n if (errorText) {\n errorMessage = errorText\n }\n }\n\n throw new NitroPingError(\n errorMessage,\n 'API_ERROR',\n response.status,\n )\n }\n\n const contentType = response.headers.get('content-type')\n if (contentType && contentType.includes('application/json')) {\n return response.json()\n }\n\n return response.text() as T\n}\n\n/**\n * Stores data in localStorage with error handling\n */\nexport function setLocalStorage(key: string, value: any): void {\n try {\n globalThis.localStorage.setItem(key, JSON.stringify(value))\n }\n catch (error) {\n console.warn('Failed to save to localStorage:', error)\n }\n}\n\n/**\n * Retrieves data from localStorage with error handling\n */\nexport function getLocalStorage<T = any>(key: string): T | null {\n try {\n const item = globalThis.localStorage.getItem(key)\n return item ? JSON.parse(item) : null\n }\n catch (error) {\n console.warn('Failed to read from localStorage:', error)\n return null\n }\n}\n\n/**\n * Removes data from localStorage\n */\nexport function removeLocalStorage(key: string): void {\n try {\n globalThis.localStorage.removeItem(key)\n }\n catch (error) {\n console.warn('Failed to remove from localStorage:', error)\n }\n}\n\n/**\n * Detects the browser type from user agent\n */\nexport function detectBrowser(): string {\n const userAgent = navigator.userAgent.toLowerCase()\n\n if (userAgent.includes('chrome') && !userAgent.includes('edg')) {\n return 'chrome'\n }\n if (userAgent.includes('firefox')) {\n return 'firefox'\n }\n if (userAgent.includes('safari') && !userAgent.includes('chrome')) {\n return 'safari'\n }\n if (userAgent.includes('edg')) {\n return 'edge'\n }\n if (userAgent.includes('opera')) {\n return 'opera'\n }\n\n return 'unknown'\n}\n\n/**\n * Detects the operating system from user agent\n */\nexport function detectOS(): string {\n const userAgent = navigator.userAgent.toLowerCase()\n\n if (userAgent.includes('mac')) {\n return 'mac'\n }\n if (userAgent.includes('win')) {\n return 'windows'\n }\n if (userAgent.includes('linux')) {\n return 'linux'\n }\n if (userAgent.includes('android')) {\n return 'android'\n }\n if (userAgent.includes('iphone') || userAgent.includes('ipad')) {\n return 'ios'\n }\n\n return 'unknown'\n}\n\n/**\n * Gets browser version from user agent\n */\nexport function getBrowserVersion(): string {\n const userAgent = navigator.userAgent\n const browser = detectBrowser()\n\n try {\n let match: RegExpMatchArray | null = null\n\n switch (browser) {\n case 'chrome':\n match = userAgent.match(/Chrome\\/(\\d+\\.\\d+\\.\\d+\\.\\d+)/)\n break\n case 'firefox':\n match = userAgent.match(/Firefox\\/(\\d+\\.\\d+)/)\n break\n case 'safari':\n match = userAgent.match(/Version\\/(\\d+\\.\\d+)/)\n break\n case 'edge':\n match = userAgent.match(/Edg\\/(\\d+\\.\\d+\\.\\d+\\.\\d+)/)\n break\n case 'opera':\n match = userAgent.match(/OPR\\/(\\d+\\.\\d+\\.\\d+\\.\\d+)/)\n break\n }\n\n return match ? match[1] : 'unknown'\n }\n catch {\n return 'unknown'\n }\n}\n\n/**\n * Maps browser type to category enum value (only for supported browsers)\n */\nexport function getCategoryFromBrowser(browser: string): string {\n switch (browser.toLowerCase()) {\n case 'chrome':\n return 'CHROME'\n case 'firefox':\n return 'FIREFOX'\n case 'safari':\n return 'SAFARI'\n case 'edge':\n return 'EDGE'\n case 'opera':\n return 'OPERA'\n default:\n return 'WEB' // Will be filtered out in SDK\n }\n}\n"],"mappings":"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"} |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
Install scripts
Supply chain riskInstall scripts are run when the package is installed or built. Malicious packages often use scripts that run automatically to execute payloads or fetch additional code.
Found 1 instance in 1 package
Minified code
QualityThis package contains minified code. This may be harmless in some cases where minified code is included in packaged libraries, however packages on npm should not minify code.
Found 1 instance in 1 package
No bug tracker
MaintenancePackage does not have a linked bug tracker in package.json.
Found 1 instance in 1 package
No website
QualityPackage does not have a website.
Found 1 instance in 1 package
21
31.25%478
40.18%0
-100%1
-75%434
176.43%0
-100%48381
-9.1%8
300%1
Infinity%11
1000%