#!/usr/bin/env node // Node.js 22+ example client. Uses only built-in fetch and crypto. const baseUrl = process.env.PUSH_BASE_URL?.replace(/\/+$/, ''); const sendKey = process.env.PUSH_SEND_KEY; if (!baseUrl || !sendKey) { console.error('Set PUSH_BASE_URL and PUSH_SEND_KEY in the process environment.'); process.exit(2); } const target = new URL(baseUrl); if ((target.protocol !== 'https:' && !(target.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(target.hostname))) || target.username || target.password || target.pathname !== '/' || target.search || target.hash) { console.error('PUSH_BASE_URL must be a public HTTPS root URL or a loopback HTTP root URL for local testing.'); process.exit(2); } class ApiError extends Error { constructor(status, body) { super(body?.message || `HTTP ${status}`); this.status = status; this.code = body?.code || 'HTTP_ERROR'; } } async function request(path, { method = 'GET', body, idempotencyKey } = {}) { const headers = { Authorization: `Bearer ${sendKey}`, Accept: 'application/json' }; if (body !== undefined) headers['Content-Type'] = 'application/json'; if (idempotencyKey) headers['X-Idempotency-Key'] = idempotencyKey; const response = await fetch(new URL(path, `${baseUrl}/`), { method, headers, ...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(10_000), redirect: 'error', }); const result = await response.json().catch(() => ({})); if (!response.ok) throw new ApiError(response.status, result); return result; } function safeJson(value) { console.log(JSON.stringify(value, null, 2)); } function readFields() { const raw = process.env.PUSH_FIELDS_JSON; if (!raw) return {}; const fields = JSON.parse(raw); if (!fields || typeof fields !== 'object' || Array.isArray(fields)) throw new Error('PUSH_FIELDS_JSON must be a JSON object.'); return fields; } async function send() { // Use one unique value per business event; retain it and the same body for any retry after this process exits. const idempotencyKey = process.env.PUSH_IDEMPOTENCY_KEY; if (!idempotencyKey || !/^[A-Za-z0-9_.:-]{1,128}$/.test(idempotencyKey)) { throw new Error('Set a stable, unique PUSH_IDEMPOTENCY_KEY (1-128 allowed ASCII characters) before sending.'); } const body = { title: process.env.PUSH_TITLE || 'Service update', desp: process.env.PUSH_DESCRIPTION || 'The requested task has been completed.', fields: readFields(), ...(process.env.PUSH_BUSINESS_URL ? { url: process.env.PUSH_BUSINESS_URL } : {}), }; let result; for (let attempt = 1; attempt <= 2; attempt += 1) { try { result = await request('/api/send', { method: 'POST', body, idempotencyKey }); break; } catch (error) { const transient = error instanceof TypeError || error.name === 'TimeoutError' || error.name === 'AbortError' || error instanceof ApiError && error.status >= 500; if (!transient || attempt === 2) { if (transient) throw new Error('Send result is still unknown. Retain PUSH_IDEMPOTENCY_KEY and retry this same event with the unchanged body.'); throw error; } // Retry exactly once with the same key and body. The server can return the original queued message. } } safeJson({ ok: result.ok, messageId: result.messageId, status: result.status, existing: result.existing, detailUrlAvailable: Boolean(result.detailUrl) }); if (!result.messageId) return; for (let attempt = 0; attempt < 12; attempt += 1) { const current = await request(`/api/messages/${encodeURIComponent(result.messageId)}`); const message = current.message; safeJson({ messageId: message.id, status: message.status }); if (['delivered', 'failed', 'partial', 'uncertain'].includes(message.status)) return; await new Promise((resolve) => setTimeout(resolve, 1000)); } } async function main() { const [command = 'quota', argument] = process.argv.slice(2); if (command === 'send') return send(); if (command === 'quota') return safeJson(await request('/api/quota')); if (command === 'list') { const params = new URLSearchParams({ limit: '20' }); if (argument) params.set('before', argument); const result = await request(`/api/messages?${params}`); safeJson({ ok: result.ok, messages: result.messages, nextCursor: result.nextCursor }); return; } if (command === 'status' && argument) return safeJson(await request(`/api/messages/${encodeURIComponent(argument)}`)); if (command === 'detail-link' && argument) { console.error('The detail URL grants access to message contents. Do not put it in shared logs.'); return safeJson(await request(`/api/messages/${encodeURIComponent(argument)}/detail-link`)); } throw new Error('Usage: node examples/node-client.mjs |detail-link |send>'); } main().catch((error) => { // Never include request headers or the SendKey in diagnostic output. console.error(`${error.code || 'ERROR'}${error.status ? ` (HTTP ${error.status})` : ''}: ${error.message}`); process.exitCode = 1; });