xnotif 0.1.1-beta.0 → 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yuta Kobayashi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/xnotif)](https://www.npmjs.com/package/xnotif)
4
4
  [![CI](https://github.com/yutakobayashidev/xnotif/actions/workflows/ci.yml/badge.svg)](https://github.com/yutakobayashidev/xnotif/actions/workflows/ci.yml)
5
+ [![DeepWiki](https://img.shields.io/badge/DeepWiki-yutakobayashidev%2Fxnotif-blue.svg?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACwAAAAyCAYAAAAnWDnqAAAAAXNSR0IArs4c6QAAA05JREFUaEPtmUtyEzEQhtWTQyQLHNak2AB7ZnyXZMEjXMGeK/AIi+QuHrMnbChYY7MIh8g01fJoopFb0uhhEqqcbWTp06/uv1saEDv4O3n3dV60RfP947Mm9/SQc0ICFQgzfc4CYZoTPAswgSJCCUJUnAAoRHOAUOcATwbmVLWdGoH//PB8mnKqScAhsD0kYP3j/Yt5LPQe2KvcXmGvRHcDnpxfL2zOYJ1mFwrryWTz0advv1Ut4CJgf5uhDuDj5eUcAUoahrdY/56ebRWeraTjMt/00Sh3UDtjgHtQNHwcRGOC98BJEAEymycmYcWwOprTgcB6VZ5JK5TAJ+fXGLBm3FDAmn6oPPjR4rKCAoJCal2eAiQp2x0vxTPB3ALO2CRkwmDy5WohzBDwSEFKRwPbknEggCPB/imwrycgxX2NzoMCHhPkDwqYMr9tRcP5qNrMZHkVnOjRMWwLCcr8ohBVb1OMjxLwGCvjTikrsBOiA6fNyCrm8V1rP93iVPpwaE+gO0SsWmPiXB+jikdf6SizrT5qKasx5j8ABbHpFTx+vFXp9EnYQmLx02h1QTTrl6eDqxLnGjporxl3NL3agEvXdT0WmEost648sQOYAeJS9Q7bfUVoMGnjo4AZdUMQku50McDcMWcBPvr0SzbTAFDfvJqwLzgxwATnCgnp4wDl6Aa+Ax283gghmj+vj7feE2KBBRMW3FzOpLOADl0Isb5587h/U4gGvkt5v60Z1VLG8BhYjbzRwyQZemwAd6cCR5/XFWLYZRIMpX39AR0tjaGGiGzLVyhse5C9RKC6ai42ppWPKiBagOvaYk8lO7DajerabOZP46Lby5wKjw1HCRx7p9sVMOWGzb/vA1hwiWc6jm3MvQDTogQkiqIhJV0nBQBTU+3okKCFDy9WwferkHjtxib7t3xIUQtHxnIwtx4mpg26/HfwVNVDb4oI9RHmx5WGelRVlrtiw43zboCLaxv46AZeB3IlTkwouebTr1y2NjSpHz68WNFjHvupy3q8TFn3Hos2IAk4Ju5dCo8B3wP7VPr/FGaKiG+T+v+TQqIrOqMTL1VdWV1DdmcbO8KXBz6esmYWYKPwDL5b5FA1a0hwapHiom0r/cKaoqr+27/XcrS5UwSMbQAAAABJRU5ErkJggg==)](https://deepwiki.com/yutakobayashidev/xnotif)
5
6
 
6
7
  Receive Twitter/X notifications in real-time. No API key, no scraping — just Web Push.
7
8
 
@@ -19,21 +20,55 @@ client.on("notification", (n) => {
19
20
  await client.start();
20
21
  ```
21
22
 
22
- ## How It Works
23
-
24
- ```
25
- Twitter ──▶ Mozilla Autopush (WebSocket) ──▶ AESGCM decrypt ──▶ EventEmitter
26
- ```
27
-
28
- xnotif generates an ECDH key pair, registers it with Twitter's push endpoint using your session cookies, then listens on Mozilla's Autopush WebSocket. Incoming notifications are decrypted and emitted as typed events.
29
-
30
23
  ## Install
31
24
 
32
25
  ```bash
33
- bun add xnotif
26
+ npm install xnotif
34
27
  ```
35
28
 
36
- > Requires Bun >= 1.0.0
29
+ > Requires Node.js >= 22.0.0
30
+
31
+ ## Why xnotif
32
+
33
+ - **Cookie exposure can be minimized** - Cookies are mainly used for one registration call to `POST /1.1/notifications/settings/login.json`. After registration, notifications are received through Mozilla Autopush WebSocket, so you are not continuously calling internal Twitter endpoints with cookies.
34
+ - **Lower ban-risk profile than polling/scraping** - xnotif avoids headless-browser automation and high-frequency private API polling. The runtime traffic pattern is mostly one registration plus push-stream consumption.
35
+ - **Avoid unnecessary re-registration** - If you persist `ClientState` from the `connected` event, restart with `state`, and the endpoint is unchanged, xnotif skips the registration call.
36
+ - **Simple operations** - No API key provisioning, no webhook server, and no request-signing stack. A single Node.js process can receive and process notifications.
37
+
38
+ ## Notification Payload
39
+
40
+ Each `notification` event delivers a `TwitterNotification` object:
41
+
42
+ ```jsonc
43
+ {
44
+ "title": "@jack",
45
+ "body": "just setting up my twttr",
46
+ "icon": "https://pbs.twimg.com/profile_images/...",
47
+ "timestamp": 1142974214000,
48
+ "tag": "mention_12345",
49
+ "data": {
50
+ "type": "mention",
51
+ "uri": "https://x.com/i/web/status/20",
52
+ "title": "@jack",
53
+ "body": "just setting up my twttr",
54
+ "tag": "mention_12345",
55
+ "lang": "en",
56
+ "scribe_target": "mention",
57
+ "impression_id": "abc123",
58
+ },
59
+ }
60
+ ```
61
+
62
+ Top-level fields:
63
+
64
+ | Field | Type | Description |
65
+ | ----------- | --------- | ------------------------------------------------- |
66
+ | `title` | `string` | Who triggered the notification |
67
+ | `body` | `string` | Human-readable description |
68
+ | `icon` | `string?` | Profile image URL |
69
+ | `timestamp` | `number?` | Unix epoch in milliseconds |
70
+ | `tag` | `string?` | Deduplication tag |
71
+ | `data` | `object?` | Structured metadata (see `data.type` for routing) |
37
72
 
38
73
  ## Getting Cookies
39
74
 
@@ -61,10 +96,24 @@ await client.start();
61
96
 
62
97
  ### `createClient(options)`
63
98
 
64
- | Option | Type | Required | Description |
65
- | --------- | ------------------------------------- | -------- | ---------------------- |
66
- | `cookies` | `{ auth_token: string; ct0: string }` | Yes | Session cookies |
67
- | `state` | `ClientState` | No | Restore previous state |
99
+ | Option | Type | Required | Description |
100
+ | --------- | ------------------------------------------------ | -------- | --------------------------------- |
101
+ | `cookies` | `{ auth_token: string; ct0: string }` | Yes | Session cookies |
102
+ | `state` | `ClientState` | No | Restore previous state |
103
+ | `filter` | `(notification: TwitterNotification) => boolean` | No | Predicate to filter notifications |
104
+
105
+ #### Filtering Notifications
106
+
107
+ Pass a `filter` function to receive only the notifications you care about:
108
+
109
+ ```typescript
110
+ const client = createClient({
111
+ cookies: { auth_token: "...", ct0: "..." },
112
+ filter: (n) => n.data?.type === "tweet",
113
+ });
114
+ ```
115
+
116
+ The predicate receives the decrypted `TwitterNotification` object. Return `true` to emit the notification, `false` to discard it silently. If the filter throws an exception, the notification is discarded and an `error` event is emitted.
68
117
 
69
118
  ### Events
70
119
 
@@ -86,6 +135,37 @@ await client.start();
86
135
  - `Decryptor` — AESGCM Web Push decryption (ECDH + HKDF + AES-128-GCM)
87
136
  - `AutopushClient` — Mozilla Autopush WebSocket client
88
137
 
138
+ ## How It Works
139
+
140
+ ```mermaid
141
+ sequenceDiagram
142
+ participant App as xnotif
143
+ participant Autopush as Mozilla Autopush<br/>wss://push.services.mozilla.com
144
+ participant Twitter as Twitter/X
145
+
146
+ App->>App: Generate ECDH P-256 key pair + 16-byte auth secret
147
+ App->>Autopush: WebSocket connect (subprotocol: push-notification)
148
+ Autopush-->>App: hello ACK (uaid assigned)
149
+ App->>Autopush: Register channel with VAPID key
150
+ Autopush-->>App: Push Endpoint URL
151
+
152
+ App->>Twitter: POST /1.1/notifications/settings/login.json<br/>{ token: endpoint, encryption_key1: p256dh, encryption_key2: auth }
153
+ Twitter-->>App: 200 OK
154
+
155
+ loop Real-time notifications
156
+ Twitter->>Autopush: Web Push (AESGCM encrypted payload)
157
+ Autopush->>App: WebSocket message
158
+ App->>App: ECDH shared secret (256-bit)<br/>→ HKDF-SHA256 (IKM, CEK, nonce)<br/>→ AES-128-GCM decrypt<br/>→ Strip 2-byte padding
159
+ App-->>App: Emit "notification" event
160
+ end
161
+ ```
162
+
163
+ 1. **Key generation** — Generate an ECDH P-256 key pair and a 16-byte auth secret via `crypto.subtle` (skipped when restoring from saved `state`)
164
+ 2. **Autopush connection** — Open a WebSocket to `wss://push.services.mozilla.com` with the `push-notification` subprotocol, send a `hello` handshake, then register a channel to obtain a Push Endpoint URL
165
+ 3. **Twitter registration** — POST the Push Endpoint, base64url-encoded public key, and auth secret to Twitter's `/1.1/notifications/settings/login.json`, authenticated with your session cookies (`auth_token` / `ct0`)
166
+ 4. **Receive & decrypt** — When Twitter pushes an AESGCM-encrypted payload through Autopush, derive a shared secret via ECDH, expand it with HKDF-SHA256 into a 16-byte CEK and 12-byte nonce, then decrypt with AES-128-GCM
167
+ 5. **Emit** — Parse the decrypted JSON into a `TwitterNotification` and fire it as a `notification` event
168
+
89
169
  ## License
90
170
 
91
171
  MIT
@@ -0,0 +1,115 @@
1
+ import { EventEmitter } from "events";
2
+
3
+ //#region src/types.d.ts
4
+ interface AutopushNotification {
5
+ messageType: "notification";
6
+ channelID: string;
7
+ version: string;
8
+ data: string;
9
+ headers: {
10
+ crypto_key: string;
11
+ encryption: string;
12
+ };
13
+ }
14
+ interface TwitterNotification {
15
+ title: string;
16
+ body: string;
17
+ icon?: string;
18
+ timestamp?: number;
19
+ tag?: string;
20
+ data?: {
21
+ type?: string;
22
+ uri?: string;
23
+ title?: string;
24
+ body?: string;
25
+ tag?: string;
26
+ lang?: string;
27
+ bundle_text?: string;
28
+ scribe_target?: string;
29
+ impression_id?: string;
30
+ [key: string]: unknown;
31
+ };
32
+ [key: string]: unknown;
33
+ }
34
+ interface ClientState {
35
+ uaid: string;
36
+ channelId: string;
37
+ endpoint: string;
38
+ remoteBroadcasts: Record<string, string>;
39
+ decryptor: {
40
+ jwk: JsonWebKey;
41
+ auth: string;
42
+ };
43
+ }
44
+ interface NotificationClientOptions {
45
+ cookies: {
46
+ auth_token: string;
47
+ ct0: string;
48
+ [key: string]: string;
49
+ };
50
+ state?: ClientState;
51
+ filter?: (notification: TwitterNotification) => boolean;
52
+ }
53
+ //#endregion
54
+ //#region src/client.d.ts
55
+ interface NotificationClientEvents {
56
+ notification: [notification: TwitterNotification];
57
+ connected: [state: ClientState];
58
+ error: [error: Error];
59
+ disconnected: [];
60
+ reconnecting: [delay: number];
61
+ }
62
+ declare class NotificationClient extends EventEmitter<NotificationClientEvents> {
63
+ private autopush;
64
+ private running;
65
+ private options;
66
+ constructor(options: NotificationClientOptions);
67
+ start(): Promise<void>;
68
+ stop(): void;
69
+ }
70
+ declare function createClient(options: NotificationClientOptions): NotificationClient;
71
+ //#endregion
72
+ //#region src/decrypt.d.ts
73
+ declare class Decryptor {
74
+ private keyPair;
75
+ private publicKeyRaw;
76
+ private authSecret;
77
+ private jwk;
78
+ private constructor();
79
+ static create(jwk?: JsonWebKey, authBase64url?: string): Promise<Decryptor>;
80
+ getJwk(): JsonWebKey;
81
+ getAuthBase64url(): string;
82
+ getPublicKeyBase64url(): string;
83
+ decrypt(cryptoKeyHeader: string, encryptionHeader: string, payload: ArrayBuffer): Promise<string>;
84
+ }
85
+ //#endregion
86
+ //#region src/autopush.d.ts
87
+ interface AutopushOptions {
88
+ uaid?: string;
89
+ channelId: string;
90
+ vapidKey: string;
91
+ remoteBroadcasts?: Record<string, string>;
92
+ onNotification: (notification: AutopushNotification) => void;
93
+ onError?: (error: unknown) => void;
94
+ onDisconnected?: () => void;
95
+ onReconnecting?: (delay: number) => void;
96
+ }
97
+ declare class AutopushClient {
98
+ private options;
99
+ private ws;
100
+ private uaid;
101
+ private endpoint;
102
+ private reconnectDelay;
103
+ private closed;
104
+ private remoteBroadcasts;
105
+ constructor(options: AutopushOptions);
106
+ getUaid(): string;
107
+ getEndpoint(): string;
108
+ getRemoteBroadcasts(): Record<string, string>;
109
+ connect(): Promise<string>;
110
+ close(): void;
111
+ private send;
112
+ private reconnect;
113
+ }
114
+ //#endregion
115
+ export { AutopushClient, type AutopushNotification, type AutopushOptions, type ClientState, Decryptor, NotificationClient, type NotificationClientOptions, type TwitterNotification, createClient };