@xtremax/pigeon 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +40 -1
- package/bin/pigeon.mjs +92 -0
- package/dist/cjs/client.js +3 -0
- package/dist/cjs/inbox/client.js +4 -9
- package/dist/cjs/person.js +10 -0
- package/dist/cjs/preferences/browser-stub.js +10 -0
- package/dist/cjs/preferences/client.js +21 -0
- package/dist/cjs/preferences/handler.js +96 -0
- package/dist/cjs/preferences/index.js +8 -0
- package/dist/cjs/preferences-react/index.js +18 -0
- package/dist/cjs/preferences-react/labels.js +32 -0
- package/dist/cjs/preferences-react/notification-preferences.js +76 -0
- package/dist/cjs/preferences-react/preferences-client.js +72 -0
- package/dist/esm/client.js +3 -0
- package/dist/esm/inbox/client.js +2 -7
- package/dist/esm/person.js +7 -0
- package/dist/esm/preferences/browser-stub.js +7 -0
- package/dist/esm/preferences/client.js +18 -0
- package/dist/esm/preferences/handler.js +92 -0
- package/dist/esm/preferences/index.js +2 -0
- package/dist/esm/preferences-react/index.js +13 -0
- package/dist/esm/preferences-react/labels.js +28 -0
- package/dist/esm/preferences-react/notification-preferences.js +73 -0
- package/dist/esm/preferences-react/preferences-client.js +66 -0
- package/dist/types/client.d.ts +5 -10
- package/dist/types/contract.d.ts +27 -0
- package/dist/types/inbox/client.d.ts +2 -2
- package/dist/types/person.d.ts +2 -0
- package/dist/types/preferences/browser-stub.d.ts +3 -0
- package/dist/types/preferences/client.d.ts +18 -0
- package/dist/types/preferences/handler.d.ts +32 -0
- package/dist/types/preferences/index.d.ts +3 -0
- package/dist/types/preferences-react/index.d.ts +14 -0
- package/dist/types/preferences-react/labels.d.ts +32 -0
- package/dist/types/preferences-react/notification-preferences.d.ts +18 -0
- package/dist/types/preferences-react/preferences-client.d.ts +22 -0
- package/package.json +5 -2
- package/skills/add-pigeon-notifications/SKILL.md +337 -0
- package/skills/add-pigeon-notifications/references/consent.instructions.md +263 -0
- package/skills/add-pigeon-notifications/references/inbox-contract.instructions.md +210 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# Pigeon inbox: the full contract
|
|
2
|
+
|
|
3
|
+
Read this when the steps in `SKILL.md` are not enough: translating the bell, placing the full list on a page of its own, reading the inbox from server code, or building a bell without React and Mantine.
|
|
4
|
+
|
|
5
|
+
## Contents
|
|
6
|
+
|
|
7
|
+
- [The package's entries](#the-packages-entries)
|
|
8
|
+
- [`InboxProvider`, `NotificationBell`, `InboxFullList`](#inboxprovider-notificationbell-inboxfulllist)
|
|
9
|
+
- [Labels](#labels)
|
|
10
|
+
- [`createInboxHandler`](#createinboxhandler)
|
|
11
|
+
- [The six operations of the route](#the-six-operations-of-the-route)
|
|
12
|
+
- [Error codes](#error-codes)
|
|
13
|
+
- [How the bell behaves](#how-the-bell-behaves)
|
|
14
|
+
- [Reading the inbox from server code](#reading-the-inbox-from-server-code)
|
|
15
|
+
- [An app without React or Mantine](#an-app-without-react-or-mantine)
|
|
16
|
+
|
|
17
|
+
## The package's entries
|
|
18
|
+
|
|
19
|
+
| Import | Runs | Holds |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| `@xtremax/pigeon/client` | server | `createCommunicationClient`, `CommunicationError` |
|
|
22
|
+
| `@xtremax/pigeon/inbox` | server | `createInboxHandler`, `createInboxClient` |
|
|
23
|
+
| `@xtremax/pigeon/inbox-react` | browser | `InboxProvider`, `NotificationBell`, `InboxFullList`, `DEFAULT_INBOX_LABELS` |
|
|
24
|
+
| `@xtremax/pigeon/inbox-react/styles.css` | browser | the bell's stylesheet |
|
|
25
|
+
| `@xtremax/pigeon/preferences` | server | `createPreferencesHandler`, `createPreferencesClient` (see `consent.instructions.md`) |
|
|
26
|
+
| `@xtremax/pigeon/preferences-react` | browser | `NotificationPreferences`, `DEFAULT_PREFERENCE_LABELS` |
|
|
27
|
+
| `@xtremax/pigeon/events` | server | `createEventHandler`, `verifyEventSignature` (delivery events; not needed for the bell) |
|
|
28
|
+
| `@xtremax/pigeon` | server | all server entries, plus `deriveIdempotencyKey`, `newIdempotencyKey` |
|
|
29
|
+
|
|
30
|
+
The server entries hold the key. Imported into a browser bundle they resolve to a stub that throws `server_only`. `inbox-react` and `preferences-react` hold no key and call only the app's own routes.
|
|
31
|
+
|
|
32
|
+
The names in the code say `Communication` (`createCommunicationClient`, `COMMUNICATION_API_KEY`). That is Pigeon's name on the Buildpad platform, where it is the Communication connector. It is the same service.
|
|
33
|
+
|
|
34
|
+
## `InboxProvider`, `NotificationBell`, `InboxFullList`
|
|
35
|
+
|
|
36
|
+
`InboxProvider` props, all optional:
|
|
37
|
+
|
|
38
|
+
| Prop | Default | Use |
|
|
39
|
+
|---|---|---|
|
|
40
|
+
| `apiBase` | `/api/communication/inbox` | Where the app mounted the route. A path on the app's own origin only: an absolute URL is refused. |
|
|
41
|
+
| `labels` | English | Replaces any visible or spoken text. See [Labels](#labels). |
|
|
42
|
+
| `locale` | the browser's | Formats times, for example `'id'` or `'en-GB'`. |
|
|
43
|
+
| `onViewAll` | opens the full list in a dialog | Call your router to open a page of your own instead. |
|
|
44
|
+
| `onOpenPreferences` | opens the preference dialog | Open a settings page of your own instead. |
|
|
45
|
+
| `openLink` | a new tab, `noopener,noreferrer` | Open a notification's link your own way, for example in the same tab. |
|
|
46
|
+
|
|
47
|
+
`labels` is a plain object, so it can be passed from a server component. The three callbacks are functions: to use one, render `InboxProvider` from a client component (`'use client'`).
|
|
48
|
+
|
|
49
|
+
`NotificationBell` takes `size` (pixels, never under 44). It must be inside `InboxProvider`.
|
|
50
|
+
|
|
51
|
+
`InboxFullList` is the full, paged list. Use it with `onViewAll` to give notifications a page of their own. It needs an `InboxProvider` above it, so put the provider around the whole signed-in layout: the header's bell and the page's list then share one, and the count is asked for once.
|
|
52
|
+
|
|
53
|
+
```tsx
|
|
54
|
+
'use client';
|
|
55
|
+
import { InboxFullList, InboxProvider, NotificationBell } from '@xtremax/pigeon/inbox-react';
|
|
56
|
+
import { useRouter } from 'next/navigation';
|
|
57
|
+
|
|
58
|
+
// around the signed-in layout: the header with the bell, and the pages
|
|
59
|
+
const router = useRouter();
|
|
60
|
+
<InboxProvider onViewAll={() => router.push('/notifications')}>
|
|
61
|
+
<header><NotificationBell /></header>
|
|
62
|
+
{children}
|
|
63
|
+
</InboxProvider>
|
|
64
|
+
|
|
65
|
+
// on app/(authenticated)/notifications/page.tsx
|
|
66
|
+
<InboxFullList maxHeight="70vh" />
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Labels
|
|
70
|
+
|
|
71
|
+
Pass any of these in `labels`. `{n}` and `{label}` are filled in.
|
|
72
|
+
|
|
73
|
+
| Key | English default |
|
|
74
|
+
|---|---|
|
|
75
|
+
| `bell` | Notifications |
|
|
76
|
+
| `bellWithCount` | {label}, {n} unread |
|
|
77
|
+
| `heading` | Notifications |
|
|
78
|
+
| `tabAll` | All |
|
|
79
|
+
| `tabUnread` | Unread |
|
|
80
|
+
| `tabUnreadWithCount` | Unread ({n}) |
|
|
81
|
+
| `markAllRead` | Mark all as read |
|
|
82
|
+
| `viewAll` | View all |
|
|
83
|
+
| `preferences` | Notification preferences |
|
|
84
|
+
| `emptyAll` | You have no notifications. |
|
|
85
|
+
| `emptyUnread` | You have no unread notifications. |
|
|
86
|
+
| `loading` | Loading notifications |
|
|
87
|
+
| `loadError` | Notifications could not be loaded. |
|
|
88
|
+
| `retry` | Try again |
|
|
89
|
+
| `actionError` | That did not work. Try again. |
|
|
90
|
+
| `dismiss` | Dismiss |
|
|
91
|
+
| `close` | Close |
|
|
92
|
+
| `unread` | Unread |
|
|
93
|
+
| `announcement` | Announcement |
|
|
94
|
+
| `loadMore` | Show older notifications |
|
|
95
|
+
| `newOne` | 1 new notification |
|
|
96
|
+
| `newMany` | {n} new notifications |
|
|
97
|
+
| `fullListTitle` | All notifications |
|
|
98
|
+
| `preferencesTitle` | Notification preferences |
|
|
99
|
+
| `preferenceToggle` | Receive in-app notifications |
|
|
100
|
+
| `preferenceOn` | New notifications from this app appear here. |
|
|
101
|
+
| `preferenceOff` | New notifications from this app do not appear here. The ones you already have stay. Other apps are not affected. |
|
|
102
|
+
| `preferenceLoadError` | Your preference could not be loaded. |
|
|
103
|
+
| `preferenceSaveError` | Your choice was not saved. Try again. |
|
|
104
|
+
|
|
105
|
+
The titles and bodies of notifications are not labels: they are written by whoever sent them, in the language they chose.
|
|
106
|
+
|
|
107
|
+
## `createInboxHandler`
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
createInboxHandler(options): (request: Request) => Promise<Response>
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
| Option | Default | Use |
|
|
114
|
+
|---|---|---|
|
|
115
|
+
| `resolvePerson` (required) | | `(request) => string \| null`, sync or async. The signed-in person's id from the app's own session. `null` or an empty string means nobody. At most 128 characters. |
|
|
116
|
+
| `client` | made on the first request from `COMMUNICATION_API_KEY` and `COMMUNICATION_URL` | Pass `createInboxClient({ apiKey, baseUrl })` to use other settings. |
|
|
117
|
+
| `basePath` | `/api/communication/inbox` | Set it when the route file is somewhere else. It must match `apiBase` on `InboxProvider`. |
|
|
118
|
+
| `allowedOrigins` | the request's own origin | Origins that may send `POST` and `PUT`. Needed only when pages are served from another origin than the route. |
|
|
119
|
+
| `timeoutMs` | 8000 | Bounds each call to Pigeon. |
|
|
120
|
+
| `onError` | | Told about every failure answered as `unavailable`. Log it: the browser is never told the cause. |
|
|
121
|
+
|
|
122
|
+
The handler takes a standard `Request` and answers a standard `Response`, so it works in any server that speaks the Fetch API, not only Next.js. Export it for `GET`, `POST` and `PUT`.
|
|
123
|
+
|
|
124
|
+
## The six operations of the route
|
|
125
|
+
|
|
126
|
+
All paths are under the base path. Every answer is JSON with `Cache-Control: no-store`. A success is `{ "data": ... }`; a failure is `{ "error": { "code", "message", "field"? } }`.
|
|
127
|
+
|
|
128
|
+
| Method and path | Request | `data` in the answer |
|
|
129
|
+
|---|---|---|
|
|
130
|
+
| `GET /summary` | | `{ unreadCount, newestUnreadAt, pollSeconds }` |
|
|
131
|
+
| `GET /notifications` | query `limit` (1 to 100, default 20), `cursor`, `unread=true` | `{ notifications: [...], nextCursor, unreadCount }` |
|
|
132
|
+
| `POST /notifications/{id}/read` | empty JSON body | `{ notification, unreadCount }` |
|
|
133
|
+
| `POST /read-all` | `{ "upTo"?: ISO time }` | `{ marked, remaining, unreadCount }`; call again while `remaining > 0` |
|
|
134
|
+
| `GET /preference` | | `{ state, changedAt, delivers }` |
|
|
135
|
+
| `PUT /preference` | `{ "state": "opted_in" \| "opted_out" }` | `{ state, changedAt, delivers }` |
|
|
136
|
+
|
|
137
|
+
A notification:
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
interface InboxNotification {
|
|
141
|
+
id: string;
|
|
142
|
+
kind: 'notification' | 'announcement';
|
|
143
|
+
title: string; // plain text; never render it as markup
|
|
144
|
+
body: string; // plain text
|
|
145
|
+
link: string | null; // absolute https, or null
|
|
146
|
+
source: { type: 'xapp' | 'tenant'; name: string };
|
|
147
|
+
createdAt: string; // ISO time
|
|
148
|
+
read: boolean;
|
|
149
|
+
readAt: string | null;
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
- `nextCursor` is `null` on the last page. Pass it back as `cursor` unchanged.
|
|
154
|
+
- `state` is `opted_in`, `opted_out` or `not_set`; `delivers` says whether new notifications reach the person now.
|
|
155
|
+
- `POST` and `PUT` must come from the app's own pages: the handler checks `Sec-Fetch-Site: same-origin` or the `Origin` header.
|
|
156
|
+
|
|
157
|
+
## Error codes
|
|
158
|
+
|
|
159
|
+
| Code | Status | Meaning |
|
|
160
|
+
|---|---|---|
|
|
161
|
+
| `not_signed_in` | 401 | `resolvePerson` returned nobody. The bell stops asking, silently. |
|
|
162
|
+
| `not_permitted` | 403 | A `POST` or `PUT` from another origin, or Pigeon refused the key for this call. |
|
|
163
|
+
| `not_found` | 404 | No such operation, or a notification that is not this person's or not this app's. |
|
|
164
|
+
| `invalid_input` | 422 | A value from the browser was refused; `field` names it. |
|
|
165
|
+
| `rate_limited` | 429 | Too many calls. |
|
|
166
|
+
| `unavailable` | 503 | Everything else: a wrong or revoked key, a timeout, Pigeon not reachable. The cause goes to `onError` only. |
|
|
167
|
+
|
|
168
|
+
Errors thrown by `createCommunicationClient` and `createInboxClient` are `CommunicationError` with `code`, `status`, `requestId` and `retryable`. Codes you may meet when sending: `channel_unavailable`, `validation_failed` (a missing or too long variable, an unknown template), `forbidden`, `invalid_credentials`, `rate_limited`, `idempotency_conflict`, `not_configured`, `timeout`, `network_error`.
|
|
169
|
+
|
|
170
|
+
## How the bell behaves
|
|
171
|
+
|
|
172
|
+
- **Updates.** It asks for the count every 60 seconds (Pigeon's setting, never under 15). It pauses while the page is hidden and asks at once when it is visible again. After failures it waits longer, up to 5 minutes. There is no realtime push.
|
|
173
|
+
- **Outage.** It keeps the last count and the page keeps working. A list that cannot load shows "Try again".
|
|
174
|
+
- **Selecting a notification** marks it read, and opens its link when it has an `https` one.
|
|
175
|
+
- **"Mark all as read"** covers what this bell shows, never another app's notifications.
|
|
176
|
+
- **Announcements** are one copy per person: read in one app's bell, they are read in every bell.
|
|
177
|
+
- **The preference** is this app's own. Switching it off stops this app's notifications for the person; other apps and announcements go on.
|
|
178
|
+
- **Nothing is kept in the browser's storage.**
|
|
179
|
+
|
|
180
|
+
## Reading the inbox from server code
|
|
181
|
+
|
|
182
|
+
For a count in a server-rendered page, a digest email or a test. Each call takes the person id first.
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
import { createInboxClient } from '@xtremax/pigeon/inbox';
|
|
186
|
+
import { communication } from '@/lib/communication/client';
|
|
187
|
+
|
|
188
|
+
const inbox = createInboxClient(communication()); // shares the key and settings
|
|
189
|
+
|
|
190
|
+
const { unreadCount } = await inbox.summary(personId);
|
|
191
|
+
const page = await inbox.list(personId, { limit: 20, unreadOnly: true });
|
|
192
|
+
await inbox.markRead(personId, page.notifications[0].id);
|
|
193
|
+
await inbox.markAllRead(personId);
|
|
194
|
+
const preference = await inbox.getPreference(personId);
|
|
195
|
+
await inbox.setPreference(personId, 'opted_out');
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
These throw `CommunicationError` on failure. The person id here is trusted, so it must come from your own session or data, never from a request.
|
|
199
|
+
|
|
200
|
+
## An app without React or Mantine
|
|
201
|
+
|
|
202
|
+
The route is the same: mount `createInboxHandler` on a catch-all route of the app's server. Then build the bell with the app's own components against the six operations above:
|
|
203
|
+
|
|
204
|
+
1. Ask `GET /summary` when the page loads, then every `pollSeconds`. Stop on `401`. Wait longer after a failure.
|
|
205
|
+
2. Show `unreadCount` on the bell (`99+` above 99).
|
|
206
|
+
3. On open, `GET /notifications`. Show `title` and `body` as text. Offer `nextCursor` as "show older".
|
|
207
|
+
4. On select, `POST /notifications/{id}/read`, and open `link` in a new tab with `noopener,noreferrer` only when it starts with `https://`.
|
|
208
|
+
5. Offer "mark all as read" (`POST /read-all`, repeated while `remaining > 0`) and the on/off choice (`GET` and `PUT /preference`).
|
|
209
|
+
|
|
210
|
+
Send requests with `credentials: 'same-origin'`. Never call Pigeon from the browser and never put the key in the page.
|