@suphark/notify 0.3.1 → 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/README.md CHANGED
@@ -85,6 +85,38 @@ const { channels } = await getRecipient(user.email); // [{ type: "telegram
85
85
 
86
86
  หรือส่งผู้ใช้ไปผูกเองที่ `https://notify.suphark.com/me` (ล็อกอิน Pi Auth) — ผูกครั้งเดียวใช้ได้ทุกแอป
87
87
 
88
+ ### แจ้งเตือนบนเบราว์เซอร์ (web push)
89
+
90
+ hub ถือคู่กุญแจ VAPID ให้ (ไม่ต้องใช้ Firebase) — แอปเหลือแค่ service worker ของตัวเองกับปุ่มขอสิทธิ์
91
+ **subscription ผูกกับแอปที่เรียกเสมอ** เพราะ service worker อยู่ที่โดเมนของแอป ใช้ข้ามแอปไม่ได้
92
+
93
+ ```ts
94
+ import { getPushConfig, savePushSubscription, deletePushSubscription, listPushDevices } from "@suphark/notify";
95
+
96
+ // ฝั่ง client: ขอสิทธิ์แล้วส่ง subscription ไปเก็บที่ hub ผ่าน server action ของแอป
97
+ const { publicKey } = await getPushConfig(); // VAPID public key (ค่าสาธารณะ)
98
+ const reg = await navigator.serviceWorker.register("/sw.js");
99
+ const sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: publicKey });
100
+ await savePushSubscription(user.email, sub.toJSON(), { label: "Chrome บน Windows", piUserId: user.id });
101
+
102
+ await listPushDevices(user.email); // { devices: [{ id, label, enabled, endpointTail }] }
103
+ await deletePushSubscription(user.email, sub.endpoint); // ผู้ใช้ปิดที่เครื่องนั้น
104
+ ```
105
+
106
+ ส่งด้วย `notify({ channels: ["push"] })` (ไม่ระบุ channels = ส่งทุกช่องทางที่ผูกไว้ รวม push ทุกเครื่อง)
107
+ service worker ของแอปจะได้ payload JSON `{ title, body, link? }` — `body` เป็นข้อความล้วนเสมอ:
108
+
109
+ ```js
110
+ self.addEventListener("push", (event) => {
111
+ const { title, body, link } = event.data.json();
112
+ event.waitUntil(self.registration.showNotification(title, { body, data: { link } }));
113
+ });
114
+ ```
115
+
116
+ - ไม่มีเครื่องไหนเปิดไว้ → `status: "skipped", reason: "channel_not_linked"` · hub ยังไม่ตั้ง provider → `no_push_provider`
117
+ - เบราว์เซอร์ถอนสิทธิ์ (404/410) → hub ปิด subscription นั้นให้เอง ไม่ต้องตามลบ
118
+ - เปลี่ยนคู่กุญแจ VAPID ที่ hub = ผู้ใช้ทุกคนต้องกดอนุญาตใหม่ (subscription ผูกกับกุญแจที่ใช้ตอน subscribe)
119
+
88
120
  ## โหมด local (ไม่ผ่าน hub)
89
121
 
90
122
  ไม่มี `PI_NOTIFY_KEY` = SDK ส่งตรงด้วย provider ที่ตั้งใน env ของแอปเอง (engine เดียวกับที่ hub ใช้)
@@ -226,6 +258,8 @@ Pi Notify มี contract test (`apps/notify/src/test/contract/`) เก็บ b
226
258
 
227
259
  ## Changelog
228
260
 
261
+ - 0.4.0 — **web push ผ่าน hub**: `getPushConfig()`, `savePushSubscription()`, `deletePushSubscription()`, `listPushDevices()` และ `notify({ channels: ["push"] })`; hub ถือคู่กุญแจ VAPID (มาตรฐาน ไม่ใช้ Firebase) subscription ผูกต่อแอป · `NotifyChannel` เพิ่ม `"push"` และ `reason` เพิ่ม `"no_push_provider"` (เพิ่มอย่างเดียว แต่ `NotifyChannel` เป็น type ที่แอปใช้ในลายเซ็นของตัวเอง จึงออกเป็น minor ตามกติกาข้อ 4)
262
+
229
263
  - 0.3.1 (additive ทั้งหมด — แอปที่ตั้ง `^0.3.0` รับได้เลย, ใช้ร่วมกับ Pi Notify ที่ deploy อยู่ได้ทันที)
230
264
  - **timeout**: `notify()` 65 วิ, `getRecipient()`/`createLink()` 10 วิ (`NotifyOptions.timeoutMs`, ค่าเริ่มต้นที่ `NOTIFY_TIMEOUT_MS`) — เดิมไม่มี timeout เลย
231
265
  - **abort**: mailer หมด `timeoutMs` แล้วยกเลิก fetch ของ provider `hub` ผ่าน `NormalizedMessage.signal` (field ใหม่ optional) ไม่ปล่อยคำขอค้าง
package/dist/index.d.ts CHANGED
@@ -472,8 +472,11 @@ declare function classifyError(err: unknown): {
472
472
  /** เวลาพัก (วินาที) ต่อ error แต่ละแบบ ก่อนกลับมาลอง provider นั้นอีก — คูณสองทุกครั้งที่พังซ้ำ */
473
473
  declare const DEFAULT_COOLDOWN: Record<ErrorKind, number>;
474
474
 
475
- /** ช่องทางแจ้งเตือนที่ hub รองรับ — ผู้รับผูกแชทเองที่ hub (/me) หรือผ่านลิงก์ที่แอปขอจาก hub */
476
- type NotifyChannel = "telegram" | "line" | "email";
475
+ /**
476
+ * ช่องทางแจ้งเตือนที่ hub รองรับ — ผู้รับผูกแชทเองที่ hub (/me) หรือผ่านลิงก์ที่แอปขอจาก hub
477
+ * `push` = web push ของเบราว์เซอร์ ผูกต่อแอป (service worker อยู่ที่โดเมนของแอปเอง)
478
+ */
479
+ type NotifyChannel = "telegram" | "line" | "push" | "email";
477
480
  interface NotifyInput {
478
481
  /** อีเมลผู้รับ (ตรงกับบัญชี Pi Auth) — ระบุได้สูงสุด 50 คน */
479
482
  to: string | string[];
@@ -500,8 +503,11 @@ interface NotifyResultItem {
500
503
  id?: string;
501
504
  failedOver?: boolean;
502
505
  error?: string;
503
- /** app_daily_cap = แอปส่งอีเมลแทนแชทครบเพดานรายวันที่ตั้งใน Pi Notify แล้ว */
504
- reason?: "no_link" | "channel_not_linked" | "no_chat_provider" | "app_daily_cap";
506
+ /**
507
+ * app_daily_cap = แอปส่งอีเมลแทนแชทครบเพดานรายวันที่ตั้งใน Pi Notify แล้ว
508
+ * no_push_provider = hub ยังไม่ได้ตั้ง provider web push
509
+ */
510
+ reason?: "no_link" | "channel_not_linked" | "no_chat_provider" | "no_push_provider" | "app_daily_cap";
505
511
  /** (status failed) คำขอหมดงบเวลาก่อนได้เริ่มส่งรายการนี้ — ส่งซ้ำเฉพาะรายการนี้ได้ */
506
512
  deferred?: boolean;
507
513
  }
@@ -531,6 +537,21 @@ declare const NOTIFY_TIMEOUT_MS: {
531
537
  readonly notify: 65000;
532
538
  readonly recipient: 10000;
533
539
  };
540
+ /** subscription จากเบราว์เซอร์ (`PushSubscription.toJSON()`) */
541
+ interface PushSubscriptionJson {
542
+ endpoint: string;
543
+ keys: {
544
+ p256dh: string;
545
+ auth: string;
546
+ };
547
+ }
548
+ interface PushDevice {
549
+ id: string;
550
+ label: string | null;
551
+ enabled: boolean;
552
+ /** ท่อนท้ายของ endpoint ไว้แยกเครื่อง (ไม่คืนค่าเต็ม) */
553
+ endpointTail: string;
554
+ }
534
555
  /** ช่องทางที่ผู้รับผูกไว้ (จาก GET /api/v1/recipients/:email) — ไม่มี chat id */
535
556
  interface RecipientChannelInfo {
536
557
  type: string;
@@ -565,6 +586,33 @@ declare function createLink(email: string, channel?: Exclude<NotifyChannel, "ema
565
586
  url: string;
566
587
  expiresAt: string;
567
588
  }>;
589
+ /**
590
+ * VAPID public key ของ hub — ใช้เป็น `applicationServerKey` ตอน `pushManager.subscribe()`
591
+ * (ค่าสาธารณะ แคชฝั่งแอปได้) · hub ยังไม่ได้ตั้ง provider push → MailError kind "server"
592
+ */
593
+ declare function getPushConfig(opts?: NotifyOptions): Promise<{
594
+ publicKey: string;
595
+ }>;
596
+ /**
597
+ * เก็บ subscription ของเบราว์เซอร์หนึ่งเครื่องไว้ที่ hub (ผูกกับแอปที่เรียกเสมอ)
598
+ * เรียกซ้ำด้วย endpoint เดิมได้ = อัปเดตกุญแจ/เปิดใช้คืน · `piUserId` ให้ hub ลบตามบัญชีที่ถูกลบได้
599
+ */
600
+ declare function savePushSubscription(email: string, subscription: PushSubscriptionJson, opts?: NotifyOptions & {
601
+ label?: string;
602
+ piUserId?: string;
603
+ }): Promise<{
604
+ ok: true;
605
+ created: boolean;
606
+ }>;
607
+ /** ผู้ใช้ปิดแจ้งเตือนที่เครื่องนั้น (ลบได้เฉพาะ subscription ของแอปตัวเอง) */
608
+ declare function deletePushSubscription(email: string, endpoint: string, opts?: NotifyOptions): Promise<{
609
+ ok: true;
610
+ deleted: number;
611
+ }>;
612
+ /** เครื่องที่ผู้รับเปิดแจ้งเตือนไว้กับแอปนี้ — ไว้แสดงรายการในหน้าตั้งค่า */
613
+ declare function listPushDevices(email: string, opts?: NotifyOptions): Promise<{
614
+ devices: PushDevice[];
615
+ }>;
568
616
 
569
617
  /** Store ใน memory ของ process (ค่าเริ่มต้น) — state หายเมื่อ restart และไม่แชร์ข้าม instance */
570
618
  declare function createMemoryStore(): Store;
@@ -589,4 +637,4 @@ declare function createRedisStore(client: RedisLike, opts?: {
589
637
  prefix?: string;
590
638
  }): Store;
591
639
 
592
- export { type Address, AllProvidersFailedError, type Attachment, type Attempt, type ConsoleOptions, DEFAULT_COOLDOWN, DEFAULT_HUB_URL, type Env, type ErrorKind, type HubErrorResponse, type HubOptions, type HubSendRequest, type HubSendResponse, MailError, type Mailer, type MailerEvent, type MailerOptions, type Message, NOTIFY_TIMEOUT_MS, type NormalizedMessage, type NotifyChannel, type NotifyInput, type NotifyOptions, type NotifyResult, type NotifyResultItem, type ParsedAddress, type Provider, type ProviderConfig, type ProviderInfo, type ProviderStatus, type Quota, type QuotaUsage, type RecipientChannelInfo, type RecipientInfo, type RedisLike, type ResendOptions, SMTP_PRESETS, type SendResult, type SkipReason, type SmtpOptions, type SmtpPreset, type Store, type ZeptoMailOptions, classifyError, configureMailer, createConsoleProvider, createHubProvider, createLink, createMailer, createMemoryStore, createPresetSmtpProvider, createRedisStore, createResendProvider, createSmtpProvider, createZeptoMailProvider, formatAddress, getMailer, getRecipient, listProviders, notify, parseAddress, parseAddressList, providersFromEnv, requireFrom, resetMailer, sendMail };
640
+ export { type Address, AllProvidersFailedError, type Attachment, type Attempt, type ConsoleOptions, DEFAULT_COOLDOWN, DEFAULT_HUB_URL, type Env, type ErrorKind, type HubErrorResponse, type HubOptions, type HubSendRequest, type HubSendResponse, MailError, type Mailer, type MailerEvent, type MailerOptions, type Message, NOTIFY_TIMEOUT_MS, type NormalizedMessage, type NotifyChannel, type NotifyInput, type NotifyOptions, type NotifyResult, type NotifyResultItem, type ParsedAddress, type Provider, type ProviderConfig, type ProviderInfo, type ProviderStatus, type PushDevice, type PushSubscriptionJson, type Quota, type QuotaUsage, type RecipientChannelInfo, type RecipientInfo, type RedisLike, type ResendOptions, SMTP_PRESETS, type SendResult, type SkipReason, type SmtpOptions, type SmtpPreset, type Store, type ZeptoMailOptions, classifyError, configureMailer, createConsoleProvider, createHubProvider, createLink, createMailer, createMemoryStore, createPresetSmtpProvider, createRedisStore, createResendProvider, createSmtpProvider, createZeptoMailProvider, deletePushSubscription, formatAddress, getMailer, getPushConfig, getRecipient, listProviders, listPushDevices, notify, parseAddress, parseAddressList, providersFromEnv, requireFrom, resetMailer, savePushSubscription, sendMail };
package/dist/index.js CHANGED
@@ -1122,6 +1122,29 @@ async function createLink(email, channel = "telegram", opts = {}) {
1122
1122
  timeoutMs: NOTIFY_TIMEOUT_MS.recipient
1123
1123
  });
1124
1124
  }
1125
+ async function getPushConfig(opts = {}) {
1126
+ return hubRequest(opts, "/api/v1/push/config", { timeoutMs: NOTIFY_TIMEOUT_MS.recipient });
1127
+ }
1128
+ async function savePushSubscription(email, subscription, opts = {}) {
1129
+ const { label, piUserId, ...rest } = opts;
1130
+ return hubRequest(rest, `/api/v1/recipients/${encodeURIComponent(email)}/push`, {
1131
+ method: "POST",
1132
+ body: { subscription, ...label ? { label } : {}, ...piUserId ? { piUserId } : {} },
1133
+ timeoutMs: NOTIFY_TIMEOUT_MS.recipient
1134
+ });
1135
+ }
1136
+ async function deletePushSubscription(email, endpoint, opts = {}) {
1137
+ return hubRequest(opts, `/api/v1/recipients/${encodeURIComponent(email)}/push`, {
1138
+ method: "DELETE",
1139
+ body: { endpoint },
1140
+ timeoutMs: NOTIFY_TIMEOUT_MS.recipient
1141
+ });
1142
+ }
1143
+ async function listPushDevices(email, opts = {}) {
1144
+ return hubRequest(opts, `/api/v1/recipients/${encodeURIComponent(email)}/push`, {
1145
+ timeoutMs: NOTIFY_TIMEOUT_MS.recipient
1146
+ });
1147
+ }
1125
1148
 
1126
1149
  // src/stores/redis.ts
1127
1150
  function createRedisStore(client, opts = {}) {
@@ -1169,15 +1192,19 @@ export {
1169
1192
  createResendProvider,
1170
1193
  createSmtpProvider,
1171
1194
  createZeptoMailProvider,
1195
+ deletePushSubscription,
1172
1196
  formatAddress,
1173
1197
  getMailer,
1198
+ getPushConfig,
1174
1199
  getRecipient,
1175
1200
  listProviders,
1201
+ listPushDevices,
1176
1202
  notify,
1177
1203
  parseAddress,
1178
1204
  parseAddressList,
1179
1205
  providersFromEnv,
1180
1206
  requireFrom,
1181
1207
  resetMailer,
1208
+ savePushSubscription,
1182
1209
  sendMail
1183
1210
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suphark/notify",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "ส่งอีเมล + แจ้งเตือน Telegram ผ่าน Pi Notify hub (notify.suphark.com) หรือส่งอีเมลตรงไป Resend/SMTP/Zoho/ZeptoMail ใน API เดียว พร้อม failover อัตโนมัติ",