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 +21 -0
- package/README.md +94 -14
- package/dist/index.d.mts +115 -0
- package/dist/index.mjs +444 -0
- package/package.json +15 -11
- package/dist/autopush.d.ts +0 -29
- package/dist/autopush.d.ts.map +0 -1
- package/dist/client.d.ts +0 -20
- package/dist/client.d.ts.map +0 -1
- package/dist/decrypt.d.ts +0 -13
- package/dist/decrypt.d.ts.map +0 -1
- package/dist/index.d.ts +0 -6
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -447
- package/dist/twitter.d.ts +0 -9
- package/dist/twitter.d.ts.map +0 -1
- package/dist/types.d.ts +0 -49
- package/dist/types.d.ts.map +0 -1
- package/dist/utils.d.ts +0 -4
- package/dist/utils.d.ts.map +0 -1
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
|
[](https://www.npmjs.com/package/xnotif)
|
|
4
4
|
[](https://github.com/yutakobayashidev/xnotif/actions/workflows/ci.yml)
|
|
5
|
+
[](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
|
-
|
|
26
|
+
npm install xnotif
|
|
34
27
|
```
|
|
35
28
|
|
|
36
|
-
> Requires
|
|
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
|
|
65
|
-
| --------- |
|
|
66
|
-
| `cookies` | `{ auth_token: string; ct0: string }`
|
|
67
|
-
| `state` | `ClientState`
|
|
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
|
package/dist/index.d.mts
ADDED
|
@@ -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 };
|