Skip to content

Repository files navigation

@simplepush/sdk

TypeScript SDK for Simplepush.

ESM-only. Targets Node 20+, Bun, Deno, and modern browsers (browser usage requires a custom webSocketFactory since the standard WebSocket API can't send headers).

Install

bun add @simplepush/sdk
# or
npm install @simplepush/sdk

Quick start

import{Client}from"@simplepush/sdk";constclient=newClient({apiToken: process.env.SP_API_TOKEN!,// requiredpasswords: [["hunter2","my-topic"]],// [password, topic] pairs; or "account-pw" for the default});forawait(consteventofclient.events({since: newDate(Date.now()-86_400_000).toISOString()})){console.log(event);}

Sending

Two parallel aggregates: sendTask (multiple inputs, replies) and sendNotification (a single text/choice/actions input, no replies). Both take a single options object; the send target (topic for a personal Client, or topic / member / broadcast on an OrgClient) lives in that object. Omit the target on a personal Client to send to your own devices (a note-to-self; encrypted under the account default password when one is configured).

Ids are type-prefixed strings.taskId, subtaskId, input/reply/file ids and the like come back type-tagged — tsk_…, sub_…, inp_…, rfl_… — not bare UUIDs.

import{Client}from"@simplepush/sdk";constclient=newClient({apiToken: process.env.SP_API_TOKEN!});// A task send returns a TaskGroup: every recipient gets their OWN independent// task instance (one recipient's answers never touch another's task).constgroup=awaitclient.sendTask({topic: "deploys",title: "Deploy v1.2.3?",inputs: [{type: "choice",options: ["yes","no"],required: true}],});consttask=group.sole;// single-recipient topic; iterate group.instances for manyforawait(constevoftask.inputs()){if(ev.kind==="taskCompleted")console.log(ev.uploads);}// A notification: lighter — a single input, one completion event. A personal// topic send defaults to independent mode, so this returns a NotificationGroup// (pass `shared: true` to get one shared Notification instead).constnotifGroup=awaitclient.sendNotification({topic: "deploys",title: "Build failed",content: "main @ a1b2c3 failed 3 tests",input: {type: "choice",options: ["ack","mute"]},});forawait(constevofnotifGroup.inputs()){console.log(ev.item.reply);// NotificationTextReply | NotificationChoiceReply | NotificationActionReply | undefined}

Task inputs: text, choice (set multi: true, with optional minSelections/maxSelections), actions (styled buttons; the tapped action's stable key comes back), slider (min/max/step/unit), photo, voiceRecording, file, location. A notification input is text, choice, or actions; a notification can also carry ONE media item — image (iOS + Android) or audio (iOS only), an uploaded FileAttachment or a URL string — and critical: true (iOS Critical Alert).

// Richer inputs: action buttons, a slider, multi-choice, a photo request.constincident=awaitclient.sendTask({topic: "ops",title: "Incident 4711",inputs: [{type: "actions",required: true,actions: [{key: "ack",label: "Acknowledge",style: "primary"},{key: "escalate",label: "Escalate",style: "destructive"},]},{type: "slider",min: 0,max: 10,step: 1,unit: "sev",required: true},{type: "choice",options: ["db","api","infra"],multi: true,required: false},{type: "photo",required: false},],});forawait(constevofincident.inputs()){if(ev.item.kind!=="taskCompleted")continue;for(constuofev.item.uploads){if(u.kind==="action")console.log(ev.recipient?.name,"pressed",u.key);if(u.kind==="slider")console.log("severity",u.value);if(u.kind==="multiChoice")console.log("areas",u.values);if(u.kind==="photo")awaitu.save("./incident-4711");}}

By default each filled input arrives as an intermediate input event, then the terminal taskCompleted carries the full committed set; autoCommit: false has the recipient submit the whole form at once instead. Other send options: reply: "one-shot" | "sticky" | "one-time-per-user" opts recipients into the in-thread reply composer (collect via replies()), and contentFormat: "markdown" renders content as Markdown.

Attachments.files uploads local files alongside a task/subtask (encrypted when the send is); the SDK is environment-agnostic, so you pass the bytes. A notification takes its single media item the same way, or as a URL:

import{readFile}from"node:fs/promises";awaitclient.sendTask({topic: "reports",title: "Q3 numbers",content: "Full report attached.",files: [{filename: "q3.pdf",data: awaitreadFile("q3.pdf"),contentType: "application/pdf"}],});awaitclient.sendNotification({topic: "alerts",title: "Door cam",image: "https://cam.example/last.jpg"});

Streams accept { replay, idleMs, signal }: replay emits the buffered backlog since the send, idleMs ends the stream after that many ms of silence, and signal cancels it.

File downloads. The binary upload objects (photo/voice/file uploads and a reply's photo/file/audio) are download handles bound to the client that yielded them: read() returns the bytes (checksum-verified, decrypted on encrypted chains), save(path) writes to disk (a directory uses the file's own name), and downloadUrl() presigns the raw short-lived (~5 min) S3 URL. Downloads authenticate with the client's apiToken/apiKey and are unlimited. Failures throw DownloadError.

forawait(constevoftask.inputs()){if(ev.kind==="taskCompleted"){for(constuofev.uploads){if(u.kind==="photo")awaitu.save("./captures");}}}forawait(constroftask.replies()){if(r.kind==="reply"&&r.photo)console.log(awaitr.photo.read());}

Encryption. Pass password per send to encrypt the body; the returned handle decrypts the recipient's replies. Or configure the client's passwords with [password, topic] pairs — sends to those topics encrypt automatically (a per-send password overrides):

constclient=newClient({ apiToken,passwords: [["hunter2","deploys"]]});awaitclient.sendTask({topic: "deploys",content: "🤫"});// encrypted via the "deploys" pairawaitclient.sendTask({topic: "other",content: "!",password: "x"});// per-send password

A task can have subtasks appended to its chain. A subtask inherits the parent's recipients and encryption (no target, no password); its inputs() / replies() are scoped to it, and stream off the same shared connection.

constsub=awaittask.append({title: "One more thing",inputs: [{type: "text",required: true}],});forawait(constevofsub.inputs()){if(ev.kind==="subtaskCompleted")console.log(ev.uploads);}

Task groups

By default every recipient of a task send gets their own independent instance, grouped under a TaskGroup:

constgroup=awaitclient.sendTask({topic: "deploys",content: "check in"});group.groupId;// grptsk_… — the group handlefor(consttaskofgroup.instances){// a full per-recipient Task: its own taskId, appendToken, streamsconsole.log(task.taskId,task.recipient?.publicId,task.recipient?.name);}

group.append(...) appends a subtask to every member's chain atomically and returns one Subtask per member; pass instances: [taskId, …] to reach only some members. File attachments are uploaded ONCE for the whole batch — every sibling subtask references the same attachment.

constsubs=awaitgroup.append({content: "follow-up"});// all membersawaitgroup.append({content: "just you",instances: [group.instances[0]!.taskId]});// a subset

Pass shared: true to sendTask for shared mode — ONE task all recipients see and answer together, returned as a plain Task.

activity() (on Task and TaskGroup) merges input events AND replies over one subscription. To collect from a FRESH process, rebuild an observe-only handle from persisted ids: client.watchTaskGroup({ groupId, createdAt, members }) / client.watchNotificationGroup(...) — same streams, but no tokens and no append(); pass createdAt to backfill everything since the send.

Submissions

A submission is self-authored user content — a text body plus an optional photo and file — pushed into a user's own stream with no associated task; a task reply without the task. Submissions are created by the app; the SDK observes them on the client's feed (both Client and OrgClient):

forawait(constsubofclient.submissions({idleMs: 300_000})){// sub: Submission — body?: TextBody, photo?/file?: SubmissionFileif(sub.photo)awaitsub.photo.save("./inbox");}

photo/file/audio are download handles (read() / save() / downloadUrl()). Audio attachments carry durationSeconds (voice-recording length in seconds). Location data (latitude, longitude, accuracy, altitude, heading, speed, timestamp) is provided inline via location when available; when the parent is encrypted it is decrypted with the same key as the body. since (ISO 8601) resumes from that point, backfilling earlier submissions; idleMs ends the stream after that many ms of silence; signal cancels it.

Encrypted submissions are decrypted with your account default password (not a topic password). Pass it in passwords (a bare string):

constclient=newClient({apiToken: TOKEN,passwords: "your-default-password"});

Or pass it per call (personal Client only): client.submissions({ password }) overrides the account password for that call.

Organizations

OrgClient authenticates with the org apiKey and addresses sends with exactly one target: { topic }, { member } (by member name), or { broadcast: true }. Encryption is automatic: pass the org's master key(s) (from your org's encryption vault; the SDK can't derive them) and every send is encrypted under the current key — there are no per-send passwords. Without keys, sends go out in the clear and org ciphertext is left as-is.

import{OrgClient}from"@simplepush/sdk";constorg=newOrgClient({apiKey: process.env.SP_API_KEY!,orgMasterKey: masterKeyBytes,// Uint8Array(32)orgMasterKeyVersion: 3,// or several: orgMasterKeys: [{ version, key }]});constgroup=awaitorg.sendTask({broadcast: true,title: "All hands?",inputs: [{type: "choice",options: ["yes","no"],required: true}],});forawait(constevofgroup.inputs()){console.log(ev.recipient?.name,ev.item.kind);// recipient = the org member}

Everything else works as on a personal Client: independent-mode groups (the member handle/name rides on each instance's recipient), subtasks, streams, submissions, downloads.

About

TypeScript SDK for Simplepush.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages