@xtremax/pigeon 0.0.0-stage → 0.3.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.
Files changed (101) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +56 -2
  3. package/dist/cjs/browser-stub.js +10 -0
  4. package/dist/cjs/client.js +112 -0
  5. package/dist/cjs/contract.js +5 -0
  6. package/dist/cjs/errors.js +48 -0
  7. package/dist/cjs/events.js +62 -0
  8. package/dist/cjs/idempotency.js +14 -0
  9. package/dist/cjs/inbox/browser-stub.js +10 -0
  10. package/dist/cjs/inbox/client.js +39 -0
  11. package/dist/cjs/inbox/errors.js +44 -0
  12. package/dist/cjs/inbox/handler.js +127 -0
  13. package/dist/cjs/inbox/index.js +15 -0
  14. package/dist/cjs/inbox/origin.js +22 -0
  15. package/dist/cjs/inbox/project.js +84 -0
  16. package/dist/cjs/inbox/validate.js +59 -0
  17. package/dist/cjs/inbox-react/format.js +72 -0
  18. package/dist/cjs/inbox-react/inbox-client.js +63 -0
  19. package/dist/cjs/inbox-react/inbox-context.js +13 -0
  20. package/dist/cjs/inbox-react/inbox-full-list.js +13 -0
  21. package/dist/cjs/inbox-react/inbox-list.js +37 -0
  22. package/dist/cjs/inbox-react/inbox-panel.js +13 -0
  23. package/dist/cjs/inbox-react/inbox-provider.js +157 -0
  24. package/dist/cjs/inbox-react/inbox-state.js +88 -0
  25. package/dist/cjs/inbox-react/index.js +25 -0
  26. package/dist/cjs/inbox-react/labels.js +38 -0
  27. package/dist/cjs/inbox-react/notification-bell.js +32 -0
  28. package/dist/cjs/inbox-react/notification-item.js +17 -0
  29. package/dist/cjs/inbox-react/parse.js +94 -0
  30. package/dist/cjs/inbox-react/preference-dialog.js +49 -0
  31. package/dist/cjs/inbox-react/types.js +4 -0
  32. package/dist/cjs/inbox-react/use-inbox-polling.js +94 -0
  33. package/dist/cjs/index.js +14 -0
  34. package/dist/cjs/package.json +1 -0
  35. package/dist/cjs/retry.js +14 -0
  36. package/dist/esm/browser-stub.js +6 -0
  37. package/dist/esm/client.js +107 -0
  38. package/dist/esm/contract.js +2 -0
  39. package/dist/esm/errors.js +42 -0
  40. package/dist/esm/events.js +57 -0
  41. package/dist/esm/idempotency.js +10 -0
  42. package/dist/esm/inbox/browser-stub.js +7 -0
  43. package/dist/esm/inbox/client.js +35 -0
  44. package/dist/esm/inbox/errors.js +38 -0
  45. package/dist/esm/inbox/handler.js +124 -0
  46. package/dist/esm/inbox/index.js +5 -0
  47. package/dist/esm/inbox/origin.js +19 -0
  48. package/dist/esm/inbox/project.js +76 -0
  49. package/dist/esm/inbox/validate.js +49 -0
  50. package/dist/esm/inbox-react/format.js +64 -0
  51. package/dist/esm/inbox-react/inbox-client.js +58 -0
  52. package/dist/esm/inbox-react/inbox-context.js +9 -0
  53. package/dist/esm/inbox-react/inbox-full-list.js +10 -0
  54. package/dist/esm/inbox-react/inbox-list.js +34 -0
  55. package/dist/esm/inbox-react/inbox-panel.js +10 -0
  56. package/dist/esm/inbox-react/inbox-provider.js +154 -0
  57. package/dist/esm/inbox-react/inbox-state.js +83 -0
  58. package/dist/esm/inbox-react/index.js +18 -0
  59. package/dist/esm/inbox-react/labels.js +34 -0
  60. package/dist/esm/inbox-react/notification-bell.js +29 -0
  61. package/dist/esm/inbox-react/notification-item.js +14 -0
  62. package/dist/esm/inbox-react/parse.js +85 -0
  63. package/dist/esm/inbox-react/preference-dialog.js +46 -0
  64. package/dist/esm/inbox-react/types.js +1 -0
  65. package/dist/esm/inbox-react/use-inbox-polling.js +88 -0
  66. package/dist/esm/index.js +4 -0
  67. package/dist/esm/retry.js +9 -0
  68. package/dist/inbox-react/styles.css +65 -0
  69. package/dist/types/browser-stub.d.ts +4 -0
  70. package/dist/types/client.d.ts +94 -0
  71. package/dist/types/contract.d.ts +182 -0
  72. package/dist/types/errors.d.ts +29 -0
  73. package/dist/types/events.d.ts +26 -0
  74. package/dist/types/idempotency.d.ts +4 -0
  75. package/dist/types/inbox/browser-stub.d.ts +3 -0
  76. package/dist/types/inbox/client.d.ts +34 -0
  77. package/dist/types/inbox/errors.d.ts +13 -0
  78. package/dist/types/inbox/handler.d.ts +25 -0
  79. package/dist/types/inbox/index.d.ts +6 -0
  80. package/dist/types/inbox/origin.d.ts +5 -0
  81. package/dist/types/inbox/project.d.ts +7 -0
  82. package/dist/types/inbox/validate.d.ts +25 -0
  83. package/dist/types/inbox-react/format.d.ts +13 -0
  84. package/dist/types/inbox-react/inbox-client.d.ts +24 -0
  85. package/dist/types/inbox-react/inbox-context.d.ts +29 -0
  86. package/dist/types/inbox-react/inbox-full-list.d.ts +7 -0
  87. package/dist/types/inbox-react/inbox-list.d.ts +12 -0
  88. package/dist/types/inbox-react/inbox-panel.d.ts +2 -0
  89. package/dist/types/inbox-react/inbox-provider.d.ts +18 -0
  90. package/dist/types/inbox-react/inbox-state.d.ts +70 -0
  91. package/dist/types/inbox-react/index.d.ts +19 -0
  92. package/dist/types/inbox-react/labels.d.ts +40 -0
  93. package/dist/types/inbox-react/notification-bell.d.ts +4 -0
  94. package/dist/types/inbox-react/notification-item.d.ts +9 -0
  95. package/dist/types/inbox-react/parse.d.ts +9 -0
  96. package/dist/types/inbox-react/preference-dialog.d.ts +5 -0
  97. package/dist/types/inbox-react/types.d.ts +55 -0
  98. package/dist/types/inbox-react/use-inbox-polling.d.ts +33 -0
  99. package/dist/types/index.d.ts +6 -0
  100. package/dist/types/retry.d.ts +13 -0
  101. package/package.json +33 -4
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_INBOX_LABELS = void 0;
4
+ exports.mergeLabels = mergeLabels;
5
+ exports.DEFAULT_INBOX_LABELS = {
6
+ bell: 'Notifications',
7
+ bellWithCount: '{label}, {n} unread',
8
+ heading: 'Notifications',
9
+ tabAll: 'All',
10
+ tabUnread: 'Unread',
11
+ tabUnreadWithCount: 'Unread ({n})',
12
+ markAllRead: 'Mark all as read',
13
+ viewAll: 'View all',
14
+ preferences: 'Notification preferences',
15
+ emptyAll: 'You have no notifications.',
16
+ emptyUnread: 'You have no unread notifications.',
17
+ loading: 'Loading notifications',
18
+ loadError: 'Notifications could not be loaded.',
19
+ retry: 'Try again',
20
+ actionError: 'That did not work. Try again.',
21
+ dismiss: 'Dismiss',
22
+ close: 'Close',
23
+ unread: 'Unread',
24
+ announcement: 'Announcement',
25
+ loadMore: 'Show older notifications',
26
+ newOne: '1 new notification',
27
+ newMany: '{n} new notifications',
28
+ fullListTitle: 'All notifications',
29
+ preferencesTitle: 'Notification preferences',
30
+ preferenceToggle: 'Receive in-app notifications',
31
+ preferenceOn: 'New notifications from this app appear here.',
32
+ preferenceOff: 'New notifications from this app do not appear here. The ones you already have stay. Other apps are not affected.',
33
+ preferenceLoadError: 'Your preference could not be loaded.',
34
+ preferenceSaveError: 'Your choice was not saved. Try again.',
35
+ };
36
+ function mergeLabels(labels) {
37
+ return { ...exports.DEFAULT_INBOX_LABELS, ...(labels ?? {}) };
38
+ }
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ 'use client';
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.NotificationBell = NotificationBell;
5
+ const jsx_runtime_1 = require("react/jsx-runtime");
6
+ const react_1 = require("react");
7
+ const core_1 = require("@mantine/core");
8
+ const hooks_1 = require("@mantine/hooks");
9
+ const icons_react_1 = require("@tabler/icons-react");
10
+ const format_1 = require("./format");
11
+ const inbox_context_1 = require("./inbox-context");
12
+ const inbox_panel_1 = require("./inbox-panel");
13
+ /** The bell with its unread badge. Opens the inbox panel. Place it inside `InboxProvider`. */
14
+ function NotificationBell({ size = 44 }) {
15
+ const { state, labels, panelOpened, openPanel, closePanel, bellRef } = (0, inbox_context_1.useInbox)();
16
+ const reduceMotion = (0, hooks_1.useReducedMotion)();
17
+ const badge = (0, format_1.badgeText)(state.unreadCount);
18
+ // A rise of the count while the panel is closed is announced, without moving focus (15.4). The first count is not news.
19
+ const [announcement, setAnnouncement] = (0, react_1.useState)('');
20
+ const known = (0, react_1.useRef)(null);
21
+ (0, react_1.useEffect)(() => {
22
+ if (state.lastSyncedAt === null)
23
+ return;
24
+ const before = known.current;
25
+ known.current = state.unreadCount;
26
+ if (before === null || state.unreadCount <= before || panelOpened)
27
+ return;
28
+ const rise = state.unreadCount - before;
29
+ setAnnouncement(rise === 1 ? labels.newOne : (0, format_1.fill)(labels.newMany, { n: rise }));
30
+ }, [state.unreadCount, state.lastSyncedAt, panelOpened, labels]);
31
+ return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(core_1.Indicator, { inline: true, label: badge, disabled: !badge, size: 18, offset: 8, children: (0, jsx_runtime_1.jsxs)(core_1.Popover, { opened: panelOpened, onChange: (opened) => (opened ? openPanel() : closePanel()), position: "bottom-end", shadow: "md", trapFocus: true, returnFocus: true, closeOnEscape: true, withinPortal: true, transitionProps: reduceMotion ? { duration: 0 } : undefined, children: [(0, jsx_runtime_1.jsx)(core_1.Popover.Target, { children: (0, jsx_runtime_1.jsx)(core_1.ActionIcon, { ref: bellRef, variant: "subtle", color: "gray", radius: "xl", size: Math.max(44, size), "aria-label": (0, format_1.bellName)(labels, state.unreadCount), onClick: () => (panelOpened ? closePanel() : openPanel()), children: (0, jsx_runtime_1.jsx)(icons_react_1.IconBell, { size: 22, stroke: 1.75, "aria-hidden": "true" }) }) }), (0, jsx_runtime_1.jsx)(core_1.Popover.Dropdown, { p: 0, style: { width: 'min(380px, calc(100vw - 16px))' }, children: (0, jsx_runtime_1.jsx)(inbox_panel_1.InboxPanel, {}) })] }) }), (0, jsx_runtime_1.jsx)(core_1.VisuallyHidden, { role: "status", "aria-live": "polite", children: announcement })] }));
32
+ }
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ 'use client';
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.NotificationItem = NotificationItem;
5
+ const jsx_runtime_1 = require("react/jsx-runtime");
6
+ const core_1 = require("@mantine/core");
7
+ const format_1 = require("./format");
8
+ const inbox_context_1 = require("./inbox-context");
9
+ /**
10
+ * One notification. The whole row is one button: selecting it marks the notification read and opens its link when
11
+ * it has a safe one. Title and body are text nodes, never markup (12.14).
12
+ */
13
+ function NotificationItem({ item, now }) {
14
+ const { labels, locale, select } = (0, inbox_context_1.useInbox)();
15
+ const unread = !item.read;
16
+ return ((0, jsx_runtime_1.jsxs)(core_1.UnstyledButton, { className: "xci-row", onClick: () => select(item), "data-unread": unread || undefined, children: [(0, jsx_runtime_1.jsx)("span", { className: "xci-dot", "data-unread": unread || undefined, "aria-hidden": "true" }), (0, jsx_runtime_1.jsxs)("span", { className: "xci-content", children: [unread && (0, jsx_runtime_1.jsxs)(core_1.VisuallyHidden, { children: [labels.unread, "."] }), (0, jsx_runtime_1.jsxs)("span", { className: "xci-meta", children: [(0, jsx_runtime_1.jsx)(core_1.Text, { component: "span", size: "xs", c: "dimmed", truncate: true, children: item.source.name }), item.kind === 'announcement' && ((0, jsx_runtime_1.jsx)(core_1.Badge, { component: "span", size: "xs", variant: "light", style: { flex: 'none' }, children: labels.announcement }))] }), (0, jsx_runtime_1.jsx)(core_1.Text, { component: "span", className: "xci-line", size: "sm", fw: unread ? 700 : 400, lineClamp: 2, children: item.title }), (0, jsx_runtime_1.jsx)(core_1.Text, { component: "span", className: "xci-line", size: "sm", c: "dimmed", lineClamp: 3, children: item.body }), (0, jsx_runtime_1.jsx)(core_1.Text, { component: "time", className: "xci-line", size: "xs", c: "dimmed", dateTime: item.createdAt, title: (0, format_1.exactTime)(item.createdAt, locale), children: (0, format_1.relativeTime)(item.createdAt, now, locale) })] })] }));
17
+ }
@@ -0,0 +1,94 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseNotification = parseNotification;
4
+ exports.parseSummary = parseSummary;
5
+ exports.parsePage = parsePage;
6
+ exports.parseMarkRead = parseMarkRead;
7
+ exports.parseReadAll = parseReadAll;
8
+ exports.parsePreference = parsePreference;
9
+ exports.parseErrorCode = parseErrorCode;
10
+ const types_1 = require("./types");
11
+ /**
12
+ * Tolerant parsing (17.4): each parser takes only the fields it knows and ignores the rest at any depth, so a field
13
+ * added later changes nothing here. A value that lacks a known field gives `null`, never a half-filled object.
14
+ */
15
+ const obj = (v) => (typeof v === 'object' && v !== null && !Array.isArray(v) ? v : null);
16
+ const str = (v) => (typeof v === 'string' ? v : null);
17
+ const strOrNull = (v) => (v === null ? null : typeof v === 'string' ? v : undefined);
18
+ const count = (v) => (typeof v === 'number' && Number.isInteger(v) && v >= 0 ? v : null);
19
+ function parseNotification(v) {
20
+ const o = obj(v);
21
+ const source = obj(o?.source);
22
+ if (!o || !source)
23
+ return null;
24
+ const id = str(o.id);
25
+ const title = str(o.title);
26
+ const body = str(o.body);
27
+ const createdAt = str(o.createdAt);
28
+ const link = strOrNull(o.link);
29
+ const readAt = strOrNull(o.readAt);
30
+ const name = str(source.name);
31
+ if (id === null || title === null || body === null || createdAt === null || link === undefined || readAt === undefined || name === null)
32
+ return null;
33
+ if (o.kind !== 'notification' && o.kind !== 'announcement')
34
+ return null;
35
+ if (source.type !== 'xapp' && source.type !== 'tenant')
36
+ return null;
37
+ if (typeof o.read !== 'boolean')
38
+ return null;
39
+ return { id, kind: o.kind, title, body, link, source: { type: source.type, name }, createdAt, read: o.read, readAt };
40
+ }
41
+ function parseSummary(v) {
42
+ const o = obj(v);
43
+ if (!o)
44
+ return null;
45
+ const unreadCount = count(o.unreadCount);
46
+ const newestUnreadAt = strOrNull(o.newestUnreadAt);
47
+ const pollSeconds = count(o.pollSeconds);
48
+ return unreadCount === null || newestUnreadAt === undefined || pollSeconds === null ? null : { unreadCount, newestUnreadAt, pollSeconds };
49
+ }
50
+ function parsePage(v) {
51
+ const o = obj(v);
52
+ if (!o || !Array.isArray(o.notifications))
53
+ return null;
54
+ const notifications = [];
55
+ for (const raw of o.notifications) {
56
+ const n = parseNotification(raw);
57
+ if (!n)
58
+ return null;
59
+ notifications.push(n);
60
+ }
61
+ const nextCursor = strOrNull(o.nextCursor);
62
+ const unreadCount = count(o.unreadCount);
63
+ return nextCursor === undefined || unreadCount === null ? null : { notifications, nextCursor, unreadCount };
64
+ }
65
+ function parseMarkRead(v) {
66
+ const o = obj(v);
67
+ const notification = parseNotification(o?.notification);
68
+ const unreadCount = count(o?.unreadCount);
69
+ return notification && unreadCount !== null ? { notification, unreadCount } : null;
70
+ }
71
+ function parseReadAll(v) {
72
+ const o = obj(v);
73
+ const marked = count(o?.marked);
74
+ const remaining = count(o?.remaining);
75
+ const unreadCount = count(o?.unreadCount);
76
+ return marked === null || remaining === null || unreadCount === null ? null : { marked, remaining, unreadCount };
77
+ }
78
+ function parsePreference(v) {
79
+ const o = obj(v);
80
+ if (!o)
81
+ return null;
82
+ const changedAt = strOrNull(o.changedAt);
83
+ if (o.state !== 'opted_in' && o.state !== 'opted_out' && o.state !== 'not_set')
84
+ return null;
85
+ return changedAt === undefined || typeof o.delivers !== 'boolean' ? null : { state: o.state, changedAt, delivers: o.delivers };
86
+ }
87
+ /** The code of an error body, or the nearest one for the status. Anything unknown is `unavailable`. */
88
+ function parseErrorCode(status, body) {
89
+ const code = obj(obj(body)?.error)?.code;
90
+ if (typeof code === 'string' && types_1.INBOX_ERROR_CODES.includes(code))
91
+ return code;
92
+ const byStatus = { 401: 'not_signed_in', 403: 'not_permitted', 404: 'not_found', 422: 'invalid_input', 429: 'rate_limited' };
93
+ return byStatus[status] ?? 'unavailable';
94
+ }
@@ -0,0 +1,49 @@
1
+ "use strict";
2
+ 'use client';
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.PreferenceDialog = PreferenceDialog;
5
+ const jsx_runtime_1 = require("react/jsx-runtime");
6
+ const react_1 = require("react");
7
+ const core_1 = require("@mantine/core");
8
+ const hooks_1 = require("@mantine/hooks");
9
+ const inbox_context_1 = require("./inbox-context");
10
+ /** The person's own choice for this app's in-app notifications. Other apps are not affected. */
11
+ function PreferenceDialog({ opened, onClose }) {
12
+ const { client, labels } = (0, inbox_context_1.useInbox)();
13
+ const reduceMotion = (0, hooks_1.useReducedMotion)();
14
+ const [preference, setPreference] = (0, react_1.useState)(null);
15
+ const [status, setStatus] = (0, react_1.useState)('loading');
16
+ const [saving, setSaving] = (0, react_1.useState)(false);
17
+ const [saveFailed, setSaveFailed] = (0, react_1.useState)(false);
18
+ const load = (0, react_1.useCallback)(async () => {
19
+ setStatus('loading');
20
+ const res = await client.getPreference();
21
+ if (res.ok)
22
+ setPreference(res.data);
23
+ setStatus(res.ok ? 'ready' : 'error');
24
+ }, [client]);
25
+ (0, react_1.useEffect)(() => {
26
+ if (!opened)
27
+ return;
28
+ setSaveFailed(false);
29
+ void load();
30
+ }, [opened, load]);
31
+ // The switch moves at once; a failure moves it back and says so.
32
+ const change = async (on) => {
33
+ if (!preference || saving)
34
+ return;
35
+ const before = preference;
36
+ setSaveFailed(false);
37
+ setSaving(true);
38
+ setPreference({ ...preference, state: on ? 'opted_in' : 'opted_out', delivers: on });
39
+ const res = await client.setPreference(on ? 'opted_in' : 'opted_out');
40
+ setSaving(false);
41
+ if (res.ok)
42
+ setPreference(res.data);
43
+ else {
44
+ setPreference(before);
45
+ setSaveFailed(true);
46
+ }
47
+ };
48
+ return ((0, jsx_runtime_1.jsxs)(core_1.Modal, { opened: opened, onClose: onClose, title: labels.preferencesTitle, closeButtonProps: { 'aria-label': labels.close }, returnFocus: false, transitionProps: reduceMotion ? { duration: 0 } : undefined, children: [status === 'loading' && ((0, jsx_runtime_1.jsx)(core_1.Center, { mih: 96, role: "status", "aria-label": labels.loading, children: (0, jsx_runtime_1.jsx)(core_1.Loader, { size: "sm" }) })), status === 'error' && ((0, jsx_runtime_1.jsx)(core_1.Alert, { color: "red", variant: "light", title: labels.preferenceLoadError, children: (0, jsx_runtime_1.jsx)(core_1.Button, { variant: "default", size: "sm", mih: 44, onClick: () => void load(), children: labels.retry }) })), status === 'ready' && preference && ((0, jsx_runtime_1.jsxs)(core_1.Stack, { gap: "sm", children: [(0, jsx_runtime_1.jsx)(core_1.Switch, { label: labels.preferenceToggle, description: preference.delivers ? labels.preferenceOn : labels.preferenceOff, checked: preference.state === 'not_set' ? preference.delivers : preference.state === 'opted_in', onChange: (event) => void change(event.currentTarget.checked) }), saveFailed && ((0, jsx_runtime_1.jsx)(core_1.Alert, { color: "red", variant: "light", role: "alert", children: labels.preferenceSaveError }))] }))] }));
49
+ }
@@ -0,0 +1,4 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.INBOX_ERROR_CODES = void 0;
4
+ exports.INBOX_ERROR_CODES = ['not_signed_in', 'not_permitted', 'not_found', 'invalid_input', 'rate_limited', 'unavailable'];
@@ -0,0 +1,94 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.POLL_CAP_SECONDS = exports.POLL_FLOOR_SECONDS = void 0;
4
+ exports.nextPollDelay = nextPollDelay;
5
+ exports.startPolling = startPolling;
6
+ exports.useInboxPolling = useInboxPolling;
7
+ const react_1 = require("react");
8
+ exports.POLL_FLOOR_SECONDS = 15;
9
+ exports.POLL_CAP_SECONDS = 300;
10
+ /**
11
+ * Seconds until the next poll. After a success: the recommended interval, at least 15. After failures:
12
+ * `min(300, base * 2^failures)` with plus or minus 20 percent jitter, still between 15 and 300.
13
+ */
14
+ function nextPollDelay(failures, recommendedSeconds, random = Math.random) {
15
+ const base = Math.max(exports.POLL_FLOOR_SECONDS, Number.isFinite(recommendedSeconds) ? recommendedSeconds : 60);
16
+ if (failures <= 0)
17
+ return base;
18
+ const backoff = Math.min(exports.POLL_CAP_SECONDS, base * 2 ** Math.min(failures, 16));
19
+ const jitter = 0.8 + 0.4 * Math.min(1, Math.max(0, random()));
20
+ return Math.min(exports.POLL_CAP_SECONDS, Math.max(exports.POLL_FLOOR_SECONDS, backoff * jitter));
21
+ }
22
+ /**
23
+ * Polls now, then after each answer waits `nextPollDelay`. Pauses while the page is hidden and polls at once when it
24
+ * is visible again. `stop` (nobody is signed in) ends polling silently. Returns a function that stops it.
25
+ */
26
+ function startPolling(poll, env) {
27
+ let stopped = false;
28
+ let running = false;
29
+ let failures = 0;
30
+ let recommended = 60;
31
+ let timer = null;
32
+ const tick = async () => {
33
+ if (stopped || running || !env.isVisible())
34
+ return;
35
+ running = true;
36
+ let outcome;
37
+ try {
38
+ outcome = await poll();
39
+ }
40
+ catch {
41
+ outcome = { result: 'failed' };
42
+ }
43
+ running = false;
44
+ if (stopped)
45
+ return;
46
+ if (outcome.result === 'stop') {
47
+ stopped = true;
48
+ return;
49
+ }
50
+ if (outcome.result === 'ok') {
51
+ failures = 0;
52
+ recommended = outcome.pollSeconds;
53
+ }
54
+ else
55
+ failures += 1;
56
+ timer = env.setTimer(() => void tick(), nextPollDelay(failures, recommended, env.random) * 1000);
57
+ };
58
+ const unsubscribe = env.onVisibilityChange(() => {
59
+ if (stopped)
60
+ return;
61
+ env.clearTimer(timer);
62
+ timer = null;
63
+ if (env.isVisible())
64
+ void tick();
65
+ });
66
+ void tick();
67
+ return () => {
68
+ stopped = true;
69
+ env.clearTimer(timer);
70
+ unsubscribe();
71
+ };
72
+ }
73
+ const browserEnvironment = () => ({
74
+ isVisible: () => document.visibilityState !== 'hidden',
75
+ onVisibilityChange: (listener) => {
76
+ document.addEventListener('visibilitychange', listener);
77
+ return () => document.removeEventListener('visibilitychange', listener);
78
+ },
79
+ setTimer: (fn, ms) => window.setTimeout(fn, ms),
80
+ clearTimer: (handle) => window.clearTimeout(handle),
81
+ random: Math.random,
82
+ });
83
+ /** Runs `poll` for as long as the component is mounted and `enabled` is true. `poll` may change between renders. */
84
+ function useInboxPolling(poll, enabled = true) {
85
+ const latest = (0, react_1.useRef)(poll);
86
+ (0, react_1.useEffect)(() => {
87
+ latest.current = poll;
88
+ }, [poll]);
89
+ (0, react_1.useEffect)(() => {
90
+ if (!enabled)
91
+ return undefined;
92
+ return startPolling(() => latest.current(), browserEnvironment());
93
+ }, [enabled]);
94
+ }
@@ -0,0 +1,14 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_RETRY = exports.newIdempotencyKey = exports.deriveIdempotencyKey = exports.verifyEventSignature = exports.createEventHandler = exports.CommunicationError = exports.createCommunicationClient = void 0;
4
+ var client_1 = require("./client");
5
+ Object.defineProperty(exports, "createCommunicationClient", { enumerable: true, get: function () { return client_1.createCommunicationClient; } });
6
+ Object.defineProperty(exports, "CommunicationError", { enumerable: true, get: function () { return client_1.CommunicationError; } });
7
+ var events_1 = require("./events");
8
+ Object.defineProperty(exports, "createEventHandler", { enumerable: true, get: function () { return events_1.createEventHandler; } });
9
+ Object.defineProperty(exports, "verifyEventSignature", { enumerable: true, get: function () { return events_1.verifyEventSignature; } });
10
+ var idempotency_1 = require("./idempotency");
11
+ Object.defineProperty(exports, "deriveIdempotencyKey", { enumerable: true, get: function () { return idempotency_1.deriveIdempotencyKey; } });
12
+ Object.defineProperty(exports, "newIdempotencyKey", { enumerable: true, get: function () { return idempotency_1.newIdempotencyKey; } });
13
+ var retry_1 = require("./retry");
14
+ Object.defineProperty(exports, "DEFAULT_RETRY", { enumerable: true, get: function () { return retry_1.DEFAULT_RETRY; } });
@@ -0,0 +1 @@
1
+ {"type":"commonjs"}
@@ -0,0 +1,14 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isRetryableStatus = exports.DEFAULT_RETRY = void 0;
4
+ exports.backoffMs = backoffMs;
5
+ exports.DEFAULT_RETRY = { maxAttempts: 3, baseDelayMs: 250, capDelayMs: 5000, totalTimeoutMs: 25_000, perAttemptTimeoutMs: 10_000 };
6
+ /** Exponential backoff with full jitter, honouring Retry-After when the server sent one. */
7
+ function backoffMs(attempt, policy, retryAfterSeconds, random = Math.random) {
8
+ const exp = Math.min(policy.capDelayMs, policy.baseDelayMs * 2 ** (attempt - 1));
9
+ const jittered = Math.round(exp * (0.5 + random() * 0.5));
10
+ return retryAfterSeconds ? Math.min(policy.capDelayMs, Math.max(jittered, retryAfterSeconds * 1000)) : jittered;
11
+ }
12
+ /** Only transient failures are retried: network errors, timeouts, 429 and 5xx. Client errors never are. */
13
+ const isRetryableStatus = (status) => status === 429 || (status >= 500 && status !== 501);
14
+ exports.isRetryableStatus = isRetryableStatus;
@@ -0,0 +1,6 @@
1
+ import { CommunicationError } from './errors.js';
2
+ /** Selected by the `browser` export condition so a secret key can never be bundled into client code. */
3
+ export function createCommunicationClient() {
4
+ throw new CommunicationError({ code: 'server_only', message: 'The Communication client must run on the server.' });
5
+ }
6
+ export { CommunicationError };
@@ -0,0 +1,107 @@
1
+ import { CommunicationError, fromResponse, redact } from './errors.js';
2
+ import { newIdempotencyKey } from './idempotency.js';
3
+ import { DEFAULT_RETRY, backoffMs, isRetryableStatus } from './retry.js';
4
+ /** Where a client keeps its transport, so `@xtremax/pigeon/inbox` can reuse the key handling, retries and errors. */
5
+ export const TRANSPORT = Symbol.for('@xtremax/pigeon.transport');
6
+ const sleepDefault = (ms) => new Promise((r) => setTimeout(r, ms));
7
+ export function createCommunicationClient(options = {}) {
8
+ if (typeof window !== 'undefined' && typeof globalThis.document !== 'undefined') {
9
+ throw new CommunicationError({ code: 'server_only', message: 'The Communication client holds a secret key and must run on the server.' });
10
+ }
11
+ const env = typeof process !== 'undefined' ? process.env : {};
12
+ const apiKey = options.apiKey ?? env.COMMUNICATION_API_KEY;
13
+ const baseUrl = (options.baseUrl ?? env.COMMUNICATION_URL ?? '').replace(/\/$/, '');
14
+ if (!apiKey || !baseUrl)
15
+ throw new CommunicationError({ code: 'not_configured', message: 'Set COMMUNICATION_API_KEY and COMMUNICATION_URL (the connector provides both).' });
16
+ const policy = { ...DEFAULT_RETRY, ...options.retry };
17
+ const doFetch = options.fetch ?? fetch;
18
+ const sleep = options.sleep ?? sleepDefault;
19
+ async function request(method, path, body, opts = {}) {
20
+ const started = Date.now();
21
+ const max = opts.retry === false ? 1 : policy.maxAttempts;
22
+ const totalMs = Math.min(policy.totalTimeoutMs, opts.timeoutMs && opts.timeoutMs > 0 ? opts.timeoutMs : policy.totalTimeoutMs);
23
+ let last = null;
24
+ for (let attempt = 1; attempt <= max; attempt += 1) {
25
+ const ctrl = new AbortController();
26
+ const timer = setTimeout(() => ctrl.abort(), Math.min(policy.perAttemptTimeoutMs, Math.max(1, totalMs - (Date.now() - started))));
27
+ try {
28
+ const res = await doFetch(`${baseUrl}/api/external/v1${path}`, {
29
+ method,
30
+ headers: { ...(opts.headers ?? {}), Authorization: `Bearer ${apiKey}`, Accept: 'application/json', ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}) },
31
+ body: body === undefined ? undefined : JSON.stringify(body),
32
+ signal: ctrl.signal,
33
+ redirect: 'error',
34
+ });
35
+ const text = await res.text();
36
+ let parsed = null;
37
+ try {
38
+ parsed = text ? JSON.parse(text) : null;
39
+ }
40
+ catch {
41
+ if (res.ok)
42
+ throw new CommunicationError({ code: 'invalid_response', message: 'The service answered with something that is not JSON', status: res.status, attempts: attempt });
43
+ }
44
+ if (res.ok)
45
+ return { data: parsed.data, headers: res.headers };
46
+ last = fromResponse(res.status, parsed, res.headers, attempt);
47
+ if (!isRetryableStatus(res.status))
48
+ throw last;
49
+ }
50
+ catch (e) {
51
+ if (e instanceof CommunicationError) {
52
+ if (!e.retryable || e.code === 'invalid_response')
53
+ throw e;
54
+ last = e;
55
+ }
56
+ else {
57
+ const aborted = e.name === 'AbortError';
58
+ last = new CommunicationError({ code: aborted ? 'timeout' : 'network_error', message: aborted ? 'The request timed out' : 'The service could not be reached', retryable: true, attempts: attempt });
59
+ }
60
+ }
61
+ finally {
62
+ clearTimeout(timer);
63
+ }
64
+ const wait = backoffMs(attempt, policy, last?.retryAfterSeconds ?? null, options.random);
65
+ if (attempt >= max || Date.now() - started + wait >= totalMs)
66
+ break;
67
+ await sleep(wait);
68
+ }
69
+ throw new CommunicationError({ code: last?.code ?? 'unknown_error', apiCode: last?.apiCode, message: last?.message ?? 'The request failed', status: last?.status, requestId: last?.requestId, details: last?.details, retryable: true, retryAfterSeconds: last?.retryAfterSeconds, attempts: Math.min(max, policy.maxAttempts) });
70
+ }
71
+ const put = (path, body) => request('PUT', path, body).then((r) => r.data);
72
+ const enc = encodeURIComponent;
73
+ return {
74
+ messages: {
75
+ /** Sends are retried safely because every request carries an idempotency key (yours, or a random one). */
76
+ async send(req) {
77
+ const { data } = await request('POST', '/messages', { ...req, idempotencyKey: req.idempotencyKey ?? newIdempotencyKey() });
78
+ return data;
79
+ },
80
+ get: (id) => request('GET', `/messages/${enc(id)}`).then((r) => r.data),
81
+ },
82
+ channels: { list: () => request('GET', '/channels').then((r) => r.data) },
83
+ ping: () => request('GET', '/ping').then((r) => r.data),
84
+ recipients: {
85
+ put: (personId, body) => put(`/recipients/${enc(personId)}`, body),
86
+ get: (personId) => request('GET', `/recipients/${enc(personId)}`).then((r) => r.data),
87
+ async delete(personId) {
88
+ await request('DELETE', `/recipients/${enc(personId)}`);
89
+ },
90
+ putAddress: (personId, body) => put(`/recipients/${enc(personId)}/addresses`, body),
91
+ async deleteAddress(personId, addressId) {
92
+ await request('DELETE', `/recipients/${enc(personId)}/addresses/${enc(addressId)}`);
93
+ },
94
+ },
95
+ preferences: {
96
+ record: (body) => request('POST', '/preferences', body).then((r) => r.data),
97
+ },
98
+ messageClasses: {
99
+ put: (templateKey, body) => put(`/message-classes/${enc(templateKey)}`, body),
100
+ list: () => request('GET', '/message-classes').then((r) => r.data),
101
+ },
102
+ /** For logs: never prints the key. */
103
+ describe: () => redact(`CommunicationClient(${baseUrl})`),
104
+ [TRANSPORT]: request,
105
+ };
106
+ }
107
+ export { CommunicationError } from './errors.js';
@@ -0,0 +1,2 @@
1
+ /** Types mirrored from the Gateway's external API (types only; safe in any bundle). */
2
+ export const SUPPORTED_API_VERSION = 'v1';
@@ -0,0 +1,42 @@
1
+ const KEY_PATTERN = /cgk_[A-Za-z0-9]{8}_[A-Za-z0-9_-]{43}/g;
2
+ /** Removes anything shaped like a key from text that may be logged. */
3
+ export const redact = (text) => text.replace(KEY_PATTERN, 'cgk_[redacted]').replace(/Bearer\s+\S+/gi, 'Bearer [redacted]');
4
+ export class CommunicationError extends Error {
5
+ code;
6
+ apiCode;
7
+ status;
8
+ requestId;
9
+ details;
10
+ retryable;
11
+ retryAfterSeconds;
12
+ attempts;
13
+ constructor(init) {
14
+ super(redact(init.message));
15
+ this.name = 'CommunicationError';
16
+ this.code = init.code;
17
+ this.apiCode = init.apiCode ?? null;
18
+ this.status = init.status ?? null;
19
+ this.requestId = init.requestId ?? null;
20
+ this.details = init.details ?? [];
21
+ this.retryable = init.retryable ?? false;
22
+ this.retryAfterSeconds = init.retryAfterSeconds ?? null;
23
+ this.attempts = init.attempts ?? 1;
24
+ }
25
+ toJSON() {
26
+ return { name: this.name, code: this.code, message: this.message, status: this.status, requestId: this.requestId, retryable: this.retryable, attempts: this.attempts };
27
+ }
28
+ }
29
+ const KNOWN = [
30
+ 'invalid_json', 'invalid_credentials', 'forbidden', 'not_found', 'idempotency_conflict', 'payload_too_large', 'unsupported_media_type', 'validation_failed',
31
+ 'channel_unavailable', 'tls_required', 'rate_limited', 'internal_error', 'temporarily_unavailable', 'conflict',
32
+ ];
33
+ export function fromResponse(status, body, headers, attempts) {
34
+ const err = body?.error;
35
+ const apiCode = err?.code ?? null;
36
+ const code = apiCode && KNOWN.includes(apiCode) ? apiCode : 'unknown_error';
37
+ const retryAfter = Number(headers.get('retry-after'));
38
+ return new CommunicationError({
39
+ code, apiCode, status, requestId: err?.requestId ?? headers.get('x-request-id'), details: err?.details ?? [],
40
+ message: err?.message ?? `The request failed with status ${status}`, retryable: status === 429 || status >= 500, retryAfterSeconds: Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : null, attempts,
41
+ });
42
+ }
@@ -0,0 +1,57 @@
1
+ import { CommunicationError } from './errors.js';
2
+ export const DEFAULT_TOLERANCE_SECONDS = 300;
3
+ const toHex = (buf) => Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, '0')).join('');
4
+ async function hmac(secret, data) {
5
+ const key = await globalThis.crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
6
+ return toHex(await globalThis.crypto.subtle.sign('HMAC', key, new TextEncoder().encode(data)));
7
+ }
8
+ function equal(a, b) {
9
+ let diff = a.length ^ b.length;
10
+ for (let i = 0; i < Math.max(a.length, b.length); i += 1)
11
+ diff |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0);
12
+ return diff === 0;
13
+ }
14
+ /** Verifies `X-Communication-Signature` over `timestamp + "." + rawBody`. Pass the RAW body text, not a parsed object. During secret rotation the header carries two values; either may match. */
15
+ export async function verifyEventSignature(input) {
16
+ if (!input.timestamp || !input.signature)
17
+ return { valid: false, reason: 'missing' };
18
+ const ts = Number(input.timestamp);
19
+ if (!Number.isFinite(ts))
20
+ return { valid: false, reason: 'missing' };
21
+ const now = input.nowSeconds ?? Math.floor(Date.now() / 1000);
22
+ if (Math.abs(now - ts) > (input.toleranceSeconds ?? DEFAULT_TOLERANCE_SECONDS))
23
+ return { valid: false, reason: 'stale' };
24
+ const presented = input.signature.split(/[,\s]+/).filter(Boolean);
25
+ for (const secret of Array.isArray(input.secret) ? input.secret : [input.secret]) {
26
+ const expected = `v1=${await hmac(secret, `${input.timestamp}.${input.rawBody}`)}`;
27
+ if (presented.some((p) => equal(p, expected)))
28
+ return { valid: true };
29
+ }
30
+ return { valid: false, reason: 'mismatch' };
31
+ }
32
+ /** A ready-made handler for a Next.js route or any Request/Response runtime. Answers 2xx only after `onEvent` succeeded. */
33
+ export function createEventHandler(options) {
34
+ return async (req) => {
35
+ const rawBody = await req.text();
36
+ const verdict = await verifyEventSignature({ secret: options.secret, timestamp: req.headers.get('x-communication-timestamp'), signature: req.headers.get('x-communication-signature'), rawBody, toleranceSeconds: options.toleranceSeconds });
37
+ if (!verdict.valid)
38
+ return new Response(null, { status: 401 });
39
+ let event;
40
+ try {
41
+ event = JSON.parse(rawBody);
42
+ }
43
+ catch {
44
+ return new Response(null, { status: 400 });
45
+ }
46
+ if (event.type !== 'message.state_changed' || event.version !== 1)
47
+ return new Response(null, { status: 204 }); // unknown future types are acknowledged and ignored
48
+ try {
49
+ if (!(await options.alreadyProcessed?.(event)))
50
+ await options.onEvent(event);
51
+ }
52
+ catch (e) {
53
+ throw new CommunicationError({ code: 'unknown_error', message: e instanceof Error ? e.message : 'The event handler failed', retryable: true });
54
+ }
55
+ return new Response(null, { status: 204 });
56
+ };
57
+ }
@@ -0,0 +1,10 @@
1
+ /** A random key for a send you will not retry yourself. Prefer `deriveIdempotencyKey` so your own retries are safe too. */
2
+ export function newIdempotencyKey() {
3
+ return `sdk-${globalThis.crypto.randomUUID()}`;
4
+ }
5
+ /** A stable key derived from parts you choose, for example an order id and an event name. Same input, same key. */
6
+ export async function deriveIdempotencyKey(...parts) {
7
+ const data = new TextEncoder().encode(parts.map(String).join('\u001f'));
8
+ const hash = await globalThis.crypto.subtle.digest('SHA-256', data);
9
+ return `d-${Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, 48)}`;
10
+ }
@@ -0,0 +1,7 @@
1
+ import { CommunicationError } from '../errors.js';
2
+ const serverOnly = () => {
3
+ throw new CommunicationError({ code: 'server_only', message: 'The Communication inbox client holds a secret key and must run on the server.' });
4
+ };
5
+ /** Selected by the `browser` export condition: the inbox entry never reaches client code. The component talks to the host's own routes instead. */
6
+ export const createInboxClient = serverOnly;
7
+ export const createInboxHandler = serverOnly;