xnotif 0.1.1-beta.0 → 0.2.0-beta.1

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
@@ -19,21 +19,48 @@ client.on("notification", (n) => {
19
19
  await client.start();
20
20
  ```
21
21
 
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
22
  ## Install
31
23
 
32
24
  ```bash
33
- bun add xnotif
25
+ npm install xnotif
26
+ ```
27
+
28
+ > Requires Node.js >= 22.0.0
29
+
30
+ ## Notification Payload
31
+
32
+ Each `notification` event delivers a `TwitterNotification` object:
33
+
34
+ ```jsonc
35
+ {
36
+ "title": "@jack",
37
+ "body": "just setting up my twttr",
38
+ "icon": "https://pbs.twimg.com/profile_images/...",
39
+ "timestamp": 1142974214000,
40
+ "tag": "mention_12345",
41
+ "data": {
42
+ "type": "mention",
43
+ "uri": "https://x.com/i/web/status/20",
44
+ "title": "@jack",
45
+ "body": "just setting up my twttr",
46
+ "tag": "mention_12345",
47
+ "lang": "en",
48
+ "scribe_target": "mention",
49
+ "impression_id": "abc123",
50
+ },
51
+ }
34
52
  ```
35
53
 
36
- > Requires Bun >= 1.0.0
54
+ Top-level fields:
55
+
56
+ | Field | Type | Description |
57
+ | ----------- | --------- | ------------------------------------------------- |
58
+ | `title` | `string` | Who triggered the notification |
59
+ | `body` | `string` | Human-readable description |
60
+ | `icon` | `string?` | Profile image URL |
61
+ | `timestamp` | `number?` | Unix epoch in milliseconds |
62
+ | `tag` | `string?` | Deduplication tag |
63
+ | `data` | `object?` | Structured metadata (see `data.type` for routing) |
37
64
 
38
65
  ## Getting Cookies
39
66
 
@@ -86,6 +113,37 @@ await client.start();
86
113
  - `Decryptor` — AESGCM Web Push decryption (ECDH + HKDF + AES-128-GCM)
87
114
  - `AutopushClient` — Mozilla Autopush WebSocket client
88
115
 
116
+ ## How It Works
117
+
118
+ ```mermaid
119
+ sequenceDiagram
120
+ participant App as xnotif
121
+ participant Autopush as Mozilla Autopush<br/>wss://push.services.mozilla.com
122
+ participant Twitter as Twitter/X
123
+
124
+ App->>App: Generate ECDH P-256 key pair + 16-byte auth secret
125
+ App->>Autopush: WebSocket connect (subprotocol: push-notification)
126
+ Autopush-->>App: hello ACK (uaid assigned)
127
+ App->>Autopush: Register channel with VAPID key
128
+ Autopush-->>App: Push Endpoint URL
129
+
130
+ App->>Twitter: POST /1.1/notifications/settings/login.json<br/>{ token: endpoint, encryption_key1: p256dh, encryption_key2: auth }
131
+ Twitter-->>App: 200 OK
132
+
133
+ loop Real-time notifications
134
+ Twitter->>Autopush: Web Push (AESGCM encrypted payload)
135
+ Autopush->>App: WebSocket message
136
+ App->>App: ECDH shared secret (256-bit)<br/>→ HKDF-SHA256 (IKM, CEK, nonce)<br/>→ AES-128-GCM decrypt<br/>→ Strip 2-byte padding
137
+ App-->>App: Emit "notification" event
138
+ end
139
+ ```
140
+
141
+ 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`)
142
+ 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
143
+ 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`)
144
+ 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
145
+ 5. **Emit** — Parse the decrypted JSON into a `TwitterNotification` and fire it as a `notification` event
146
+
89
147
  ## License
90
148
 
91
149
  MIT
@@ -0,0 +1,114 @@
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
+ }
52
+ //#endregion
53
+ //#region src/client.d.ts
54
+ interface NotificationClientEvents {
55
+ notification: [notification: TwitterNotification];
56
+ connected: [state: ClientState];
57
+ error: [error: Error];
58
+ disconnected: [];
59
+ reconnecting: [delay: number];
60
+ }
61
+ declare class NotificationClient extends EventEmitter<NotificationClientEvents> {
62
+ private autopush;
63
+ private running;
64
+ private options;
65
+ constructor(options: NotificationClientOptions);
66
+ start(): Promise<void>;
67
+ stop(): void;
68
+ }
69
+ declare function createClient(options: NotificationClientOptions): NotificationClient;
70
+ //#endregion
71
+ //#region src/decrypt.d.ts
72
+ declare class Decryptor {
73
+ private keyPair;
74
+ private publicKeyRaw;
75
+ private authSecret;
76
+ private jwk;
77
+ private constructor();
78
+ static create(jwk?: JsonWebKey, authBase64url?: string): Promise<Decryptor>;
79
+ getJwk(): JsonWebKey;
80
+ getAuthBase64url(): string;
81
+ getPublicKeyBase64url(): string;
82
+ decrypt(cryptoKeyHeader: string, encryptionHeader: string, payload: ArrayBuffer): Promise<string>;
83
+ }
84
+ //#endregion
85
+ //#region src/autopush.d.ts
86
+ interface AutopushOptions {
87
+ uaid?: string;
88
+ channelId: string;
89
+ vapidKey: string;
90
+ remoteBroadcasts?: Record<string, string>;
91
+ onNotification: (notification: AutopushNotification) => void;
92
+ onError?: (error: unknown) => void;
93
+ onDisconnected?: () => void;
94
+ onReconnecting?: (delay: number) => void;
95
+ }
96
+ declare class AutopushClient {
97
+ private options;
98
+ private ws;
99
+ private uaid;
100
+ private endpoint;
101
+ private reconnectDelay;
102
+ private closed;
103
+ private remoteBroadcasts;
104
+ constructor(options: AutopushOptions);
105
+ getUaid(): string;
106
+ getEndpoint(): string;
107
+ getRemoteBroadcasts(): Record<string, string>;
108
+ connect(): Promise<string>;
109
+ close(): void;
110
+ private send;
111
+ private reconnect;
112
+ }
113
+ //#endregion
114
+ export { AutopushClient, type AutopushNotification, type AutopushOptions, type ClientState, Decryptor, NotificationClient, type NotificationClientOptions, type TwitterNotification, createClient };
package/dist/index.mjs ADDED
@@ -0,0 +1,438 @@
1
+ import { EventEmitter } from "events";
2
+ import { TwitterOpenApi } from "twitter-openapi-typescript";
3
+ import { BaseAPI } from "twitter-openapi-typescript-generated";
4
+
5
+ //#region src/utils.ts
6
+ function base64urlToBuffer(b64url) {
7
+ const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/");
8
+ const padded = b64 + "=".repeat((4 - b64.length % 4) % 4);
9
+ const binary = atob(padded);
10
+ const bytes = new Uint8Array(binary.length);
11
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
12
+ return bytes.buffer;
13
+ }
14
+ function bufferToBase64url(buffer) {
15
+ const bytes = new Uint8Array(buffer);
16
+ let binary = "";
17
+ for (const byte of bytes) binary += String.fromCharCode(byte);
18
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
19
+ }
20
+ function concatBuffers(...buffers) {
21
+ const totalLength = buffers.reduce((acc, buf) => acc + buf.byteLength, 0);
22
+ const result = new Uint8Array(totalLength);
23
+ let offset = 0;
24
+ for (const buf of buffers) {
25
+ result.set(new Uint8Array(buf), offset);
26
+ offset += buf.byteLength;
27
+ }
28
+ return result.buffer;
29
+ }
30
+
31
+ //#endregion
32
+ //#region src/decrypt.ts
33
+ var Decryptor = class Decryptor {
34
+ constructor(keyPair, publicKeyRaw, authSecret, jwk) {
35
+ this.keyPair = keyPair;
36
+ this.publicKeyRaw = publicKeyRaw;
37
+ this.authSecret = authSecret;
38
+ this.jwk = jwk;
39
+ }
40
+ static async create(jwk, authBase64url) {
41
+ let keyPair;
42
+ let savedJwk;
43
+ if (jwk) {
44
+ const privateKey = await crypto.subtle.importKey("jwk", jwk, {
45
+ name: "ECDH",
46
+ namedCurve: "P-256"
47
+ }, true, ["deriveBits"]);
48
+ const pubJwk = {
49
+ ...jwk,
50
+ d: void 0,
51
+ key_ops: []
52
+ };
53
+ delete pubJwk.d;
54
+ keyPair = {
55
+ privateKey,
56
+ publicKey: await crypto.subtle.importKey("jwk", pubJwk, {
57
+ name: "ECDH",
58
+ namedCurve: "P-256"
59
+ }, true, [])
60
+ };
61
+ savedJwk = jwk;
62
+ } else {
63
+ keyPair = await crypto.subtle.generateKey({
64
+ name: "ECDH",
65
+ namedCurve: "P-256"
66
+ }, true, ["deriveBits"]);
67
+ savedJwk = await crypto.subtle.exportKey("jwk", keyPair.privateKey);
68
+ }
69
+ const publicKeyRaw = await crypto.subtle.exportKey("raw", keyPair.publicKey);
70
+ const authSecret = authBase64url ? base64urlToBuffer(authBase64url) : crypto.getRandomValues(new Uint8Array(16)).buffer;
71
+ return new Decryptor(keyPair, publicKeyRaw, authSecret, savedJwk);
72
+ }
73
+ getJwk() {
74
+ return this.jwk;
75
+ }
76
+ getAuthBase64url() {
77
+ return bufferToBase64url(this.authSecret);
78
+ }
79
+ getPublicKeyBase64url() {
80
+ return bufferToBase64url(this.publicKeyRaw);
81
+ }
82
+ async decrypt(cryptoKeyHeader, encryptionHeader, payload) {
83
+ const cryptoKeyParams = parseHeader(cryptoKeyHeader);
84
+ const encryptionParams = parseHeader(encryptionHeader);
85
+ const remotePubKeyBytes = base64urlToBuffer(cryptoKeyParams.dh);
86
+ const salt = base64urlToBuffer(encryptionParams.salt);
87
+ const rs = encryptionParams.rs ? parseInt(encryptionParams.rs) : 0;
88
+ const remotePubKey = await crypto.subtle.importKey("raw", remotePubKeyBytes, {
89
+ name: "ECDH",
90
+ namedCurve: "P-256"
91
+ }, true, []);
92
+ const sharedSecret = new Uint8Array(await crypto.subtle.deriveBits({
93
+ name: "ECDH",
94
+ public: remotePubKey
95
+ }, this.keyPair.privateKey, 256));
96
+ const authInfo = new TextEncoder().encode("Content-Encoding: auth\0");
97
+ const ikm = await hkdf(new Uint8Array(this.authSecret), sharedSecret, authInfo, 32);
98
+ const context = concatBuffers(new TextEncoder().encode("P-256\0").buffer, new Uint8Array([0, 65]).buffer, this.publicKeyRaw, new Uint8Array([0, 65]).buffer, remotePubKeyBytes);
99
+ const cekInfo = concatBuffers(new TextEncoder().encode("Content-Encoding: aesgcm\0").buffer, context);
100
+ const cek = await hkdf(new Uint8Array(salt), ikm, new Uint8Array(cekInfo), 16);
101
+ const nonceInfo = concatBuffers(new TextEncoder().encode("Content-Encoding: nonce\0").buffer, context);
102
+ const nonce = await hkdf(new Uint8Array(salt), ikm, new Uint8Array(nonceInfo), 12);
103
+ const cekKey = await crypto.subtle.importKey("raw", cek, "AES-GCM", false, ["decrypt"]);
104
+ let decrypted;
105
+ if (rs >= 18) {
106
+ const chunks = splitPayload(payload, rs);
107
+ const parts = [];
108
+ for (let i = 0; i < chunks.length; i++) {
109
+ const chunkNonce = adjustNonce(nonce, i);
110
+ const part = await crypto.subtle.decrypt({
111
+ name: "AES-GCM",
112
+ iv: chunkNonce
113
+ }, cekKey, chunks[i]);
114
+ parts.push(part);
115
+ }
116
+ decrypted = concatBuffers(...parts);
117
+ } else decrypted = await crypto.subtle.decrypt({
118
+ name: "AES-GCM",
119
+ iv: nonce
120
+ }, cekKey, payload);
121
+ const paddingLen = new DataView(decrypted).getUint16(0);
122
+ const plaintext = decrypted.slice(2 + paddingLen);
123
+ return new TextDecoder().decode(plaintext);
124
+ }
125
+ };
126
+ function parseHeader(header) {
127
+ const result = {};
128
+ for (const part of header.split(";")) {
129
+ const trimmed = part.trim();
130
+ const eqIdx = trimmed.indexOf("=");
131
+ if (eqIdx !== -1) result[trimmed.slice(0, eqIdx).trim()] = trimmed.slice(eqIdx + 1).trim();
132
+ }
133
+ return result;
134
+ }
135
+ async function hmacSha256(key, data) {
136
+ const cryptoKey = await crypto.subtle.importKey("raw", key, {
137
+ name: "HMAC",
138
+ hash: "SHA-256"
139
+ }, false, ["sign"]);
140
+ return new Uint8Array(await crypto.subtle.sign("HMAC", cryptoKey, data));
141
+ }
142
+ async function hkdf(salt, ikm, info, length) {
143
+ const prk = await hmacSha256(salt, ikm);
144
+ const infoWithCounter = new Uint8Array(info.length + 1);
145
+ infoWithCounter.set(info);
146
+ infoWithCounter[info.length] = 1;
147
+ return (await hmacSha256(prk, infoWithCounter)).slice(0, length);
148
+ }
149
+ function splitPayload(payload, rs) {
150
+ const bytes = new Uint8Array(payload);
151
+ const chunks = [];
152
+ for (let i = 0; i < bytes.length; i += rs) chunks.push(bytes.slice(i, Math.min(i + rs, bytes.length)).buffer);
153
+ return chunks;
154
+ }
155
+ function adjustNonce(nonce, offset) {
156
+ if (offset === 0) return nonce;
157
+ const adjusted = new Uint8Array(nonce);
158
+ for (let i = 11; i >= 6; i--) adjusted[i] ^= offset >>> (11 - i) * 8 & 255;
159
+ return adjusted;
160
+ }
161
+
162
+ //#endregion
163
+ //#region src/autopush.ts
164
+ const AUTOPUSH_URL = "wss://push.services.mozilla.com/";
165
+ var AutopushClient = class {
166
+ ws = null;
167
+ uaid = "";
168
+ endpoint = "";
169
+ reconnectDelay = 1e3;
170
+ closed = false;
171
+ remoteBroadcasts;
172
+ constructor(options) {
173
+ this.options = options;
174
+ if (options.uaid) this.uaid = options.uaid;
175
+ this.remoteBroadcasts = options.remoteBroadcasts || {};
176
+ }
177
+ getUaid() {
178
+ return this.uaid;
179
+ }
180
+ getEndpoint() {
181
+ return this.endpoint;
182
+ }
183
+ getRemoteBroadcasts() {
184
+ return this.remoteBroadcasts;
185
+ }
186
+ connect() {
187
+ return new Promise((resolve, reject) => {
188
+ let resolved = false;
189
+ this.ws = new WebSocket(AUTOPUSH_URL);
190
+ this.ws.onopen = () => {
191
+ this.reconnectDelay = 1e3;
192
+ this.send({
193
+ messageType: "hello",
194
+ use_webpush: true,
195
+ uaid: this.uaid || "",
196
+ broadcasts: this.remoteBroadcasts
197
+ });
198
+ };
199
+ this.ws.onmessage = (event) => {
200
+ const msg = JSON.parse(String(event.data));
201
+ switch (msg.messageType) {
202
+ case "hello":
203
+ this.uaid = msg.uaid;
204
+ if (msg.broadcasts) this.remoteBroadcasts = msg.broadcasts;
205
+ this.send({
206
+ channelID: this.options.channelId,
207
+ messageType: "register",
208
+ key: this.options.vapidKey
209
+ });
210
+ break;
211
+ case "register":
212
+ if (msg.status === 200) {
213
+ this.endpoint = msg.pushEndpoint;
214
+ if (!resolved) {
215
+ resolved = true;
216
+ resolve(this.endpoint);
217
+ }
218
+ } else {
219
+ const err = /* @__PURE__ */ new Error(`Register failed: status ${msg.status}`);
220
+ if (!resolved) {
221
+ resolved = true;
222
+ reject(err);
223
+ }
224
+ }
225
+ break;
226
+ case "notification": {
227
+ const notification = msg;
228
+ this.send({
229
+ messageType: "ack",
230
+ updates: [{
231
+ channelID: notification.channelID,
232
+ version: notification.version,
233
+ code: 100
234
+ }]
235
+ });
236
+ this.options.onNotification(notification);
237
+ break;
238
+ }
239
+ }
240
+ };
241
+ this.ws.onclose = () => {
242
+ if (!this.closed) {
243
+ this.options.onDisconnected?.();
244
+ this.reconnect();
245
+ }
246
+ };
247
+ this.ws.onerror = (event) => {
248
+ const error = typeof ErrorEvent !== "undefined" && event instanceof ErrorEvent && event.message ? new Error(event.message) : /* @__PURE__ */ new Error("WebSocket connection failed");
249
+ this.options.onError?.(error);
250
+ if (!resolved) {
251
+ resolved = true;
252
+ reject(error);
253
+ }
254
+ };
255
+ });
256
+ }
257
+ close() {
258
+ this.closed = true;
259
+ this.ws?.close();
260
+ }
261
+ send(msg) {
262
+ this.ws?.send(JSON.stringify(msg));
263
+ }
264
+ reconnect() {
265
+ this.options.onReconnecting?.(this.reconnectDelay);
266
+ setTimeout(() => {
267
+ this.reconnectDelay = Math.min(this.reconnectDelay * 2, 6e4);
268
+ this.connect().catch((err) => {
269
+ this.options.onError?.(err);
270
+ });
271
+ }, this.reconnectDelay);
272
+ }
273
+ };
274
+
275
+ //#endregion
276
+ //#region src/twitter.ts
277
+ const API_HEADER_NAMES = [
278
+ "Accept",
279
+ "x-twitter-client-language",
280
+ "Priority",
281
+ "Referer",
282
+ "Sec-Fetch-Dest",
283
+ "Sec-Ch-Ua-Platform",
284
+ "Sec-Fetch-Mode",
285
+ "x-csrf-token",
286
+ "x-client-uuid",
287
+ "x-guest-token",
288
+ "Sec-Ch-Ua",
289
+ "x-twitter-active-user",
290
+ "user-agent",
291
+ "Accept-Language",
292
+ "Sec-Fetch-Site",
293
+ "x-twitter-auth-type",
294
+ "Sec-Ch-Ua-Mobile",
295
+ "Accept-Encoding"
296
+ ];
297
+ var NotificationApi = class extends BaseAPI {
298
+ constructor(config) {
299
+ super(config);
300
+ }
301
+ async post(path, body, initOverride) {
302
+ const headers = {};
303
+ const apiKey = this.configuration.apiKey;
304
+ if (apiKey) for (const name of API_HEADER_NAMES) {
305
+ const value = await apiKey(name);
306
+ if (value) headers[name] = value;
307
+ }
308
+ const accessToken = this.configuration.accessToken;
309
+ if (accessToken) headers["Authorization"] = `Bearer ${await accessToken()}`;
310
+ headers["Content-Type"] = "application/json";
311
+ return this.request({
312
+ path,
313
+ method: "POST",
314
+ headers,
315
+ body
316
+ }, initOverride);
317
+ }
318
+ };
319
+ async function createClient$1(cookies) {
320
+ return new TwitterOpenApi().getClientFromCookies(cookies);
321
+ }
322
+ function makeInitOverride(client, path) {
323
+ return client.initOverrides({
324
+ "@method": "POST",
325
+ "@path": path
326
+ });
327
+ }
328
+ async function registerPush(client, subscription) {
329
+ const api = new NotificationApi(client.config);
330
+ const path = "/1.1/notifications/settings/login.json";
331
+ const res = await api.post(path, { push_device_info: {
332
+ os_version: "Web/Chrome",
333
+ udid: "Web/Chrome",
334
+ env: 3,
335
+ locale: "en",
336
+ protocol_version: 1,
337
+ token: subscription.endpoint,
338
+ encryption_key1: subscription.p256dh,
339
+ encryption_key2: subscription.auth
340
+ } }, makeInitOverride(client, path));
341
+ if (!res.ok) {
342
+ const text = await res.text();
343
+ throw new Error(`login.json failed (${res.status}): ${text}`);
344
+ }
345
+ }
346
+
347
+ //#endregion
348
+ //#region src/client.ts
349
+ const VAPID_KEY = "BF5oEo0xDUpgylKDTlsd8pZmxQA1leYINiY-rSscWYK_3tWAkz4VMbtf1MLE_Yyd6iII6o-e3Q9TCN5vZMzVMEs";
350
+ var NotificationClient = class extends EventEmitter {
351
+ autopush = null;
352
+ running = false;
353
+ options;
354
+ constructor(options) {
355
+ super();
356
+ this.options = options;
357
+ }
358
+ async start() {
359
+ if (this.running) return;
360
+ this.running = true;
361
+ try {
362
+ let decryptor;
363
+ let channelId;
364
+ let uaid;
365
+ let savedEndpoint;
366
+ let remoteBroadcasts;
367
+ if (this.options.state) {
368
+ const s = this.options.state;
369
+ decryptor = await Decryptor.create(s.decryptor.jwk, s.decryptor.auth);
370
+ channelId = s.channelId;
371
+ uaid = s.uaid;
372
+ savedEndpoint = s.endpoint;
373
+ remoteBroadcasts = s.remoteBroadcasts;
374
+ } else {
375
+ decryptor = await Decryptor.create();
376
+ channelId = crypto.randomUUID();
377
+ }
378
+ this.autopush = new AutopushClient({
379
+ uaid,
380
+ channelId,
381
+ vapidKey: VAPID_KEY,
382
+ remoteBroadcasts,
383
+ onNotification: async (msg) => {
384
+ try {
385
+ const payload = base64urlToBuffer(msg.data);
386
+ const json = await decryptor.decrypt(msg.headers.crypto_key, msg.headers.encryption, payload);
387
+ const notification = JSON.parse(json);
388
+ this.emit("notification", notification);
389
+ } catch (err) {
390
+ this.emit("error", err instanceof Error ? err : new Error(String(err)));
391
+ }
392
+ },
393
+ onError: (err) => {
394
+ this.emit("error", err instanceof Error ? err : new Error(String(err)));
395
+ },
396
+ onDisconnected: () => {
397
+ this.emit("disconnected");
398
+ },
399
+ onReconnecting: (delay) => {
400
+ this.emit("reconnecting", delay);
401
+ }
402
+ });
403
+ const endpoint = await this.autopush.connect();
404
+ const needsRegistration = endpoint !== savedEndpoint;
405
+ const state = {
406
+ uaid: this.autopush.getUaid(),
407
+ channelId,
408
+ endpoint,
409
+ remoteBroadcasts: this.autopush.getRemoteBroadcasts(),
410
+ decryptor: {
411
+ jwk: decryptor.getJwk(),
412
+ auth: decryptor.getAuthBase64url()
413
+ }
414
+ };
415
+ this.emit("connected", state);
416
+ if (needsRegistration) await registerPush(await createClient$1(this.options.cookies), {
417
+ endpoint,
418
+ p256dh: decryptor.getPublicKeyBase64url(),
419
+ auth: decryptor.getAuthBase64url()
420
+ });
421
+ } catch (err) {
422
+ this.running = false;
423
+ this.autopush = null;
424
+ throw err;
425
+ }
426
+ }
427
+ stop() {
428
+ this.running = false;
429
+ this.autopush?.close();
430
+ this.autopush = null;
431
+ }
432
+ };
433
+ function createClient(options) {
434
+ return new NotificationClient(options);
435
+ }
436
+
437
+ //#endregion
438
+ export { AutopushClient, Decryptor, NotificationClient, createClient };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xnotif",
3
- "version": "0.1.1-beta.0",
3
+ "version": "0.2.0-beta.1",
4
4
  "description": "Receive Twitter/X push notifications programmatically via Mozilla Autopush",
5
5
  "keywords": [
6
6
  "autopush",
@@ -23,25 +23,29 @@
23
23
  "README.md"
24
24
  ],
25
25
  "type": "module",
26
- "types": "./dist/index.d.ts",
26
+ "types": "./dist/index.d.mts",
27
27
  "exports": {
28
28
  ".": {
29
- "types": "./dist/index.d.ts",
30
- "import": "./dist/index.js"
29
+ "types": "./dist/index.d.mts",
30
+ "import": "./dist/index.mjs"
31
31
  }
32
32
  },
33
- "scripts": {
34
- "build": "bun build src/index.ts --outdir dist --target bun --packages external && bunx tsc --emitDeclarationOnly"
35
- },
36
33
  "dependencies": {
37
34
  "twitter-openapi-typescript": "^0.0.55",
38
35
  "twitter-openapi-typescript-generated": "^0.0.38"
39
36
  },
40
37
  "devDependencies": {
41
- "@types/bun": "^1.2.0",
42
- "typescript": "^5.9.3"
38
+ "@types/node": "^22.0.0",
39
+ "@vitest/coverage-v8": "^4.0.18",
40
+ "tsdown": "^0.20.3",
41
+ "typescript": "^5.9.3",
42
+ "vitest": "^4.0.18"
43
43
  },
44
44
  "engines": {
45
- "bun": ">=1.0.0"
45
+ "node": ">=22.0.0"
46
+ },
47
+ "scripts": {
48
+ "build": "tsdown src/index.ts --format esm",
49
+ "test": "vitest run --coverage"
46
50
  }
47
- }
51
+ }
@@ -1,29 +0,0 @@
1
- import type { AutopushNotification } from "./types";
2
- export interface AutopushOptions {
3
- uaid?: string;
4
- channelId: string;
5
- vapidKey: string;
6
- remoteBroadcasts?: Record<string, string>;
7
- onNotification: (notification: AutopushNotification) => void;
8
- onError?: (error: unknown) => void;
9
- onDisconnected?: () => void;
10
- onReconnecting?: (delay: number) => void;
11
- }
12
- export declare class AutopushClient {
13
- private options;
14
- private ws;
15
- private uaid;
16
- private endpoint;
17
- private reconnectDelay;
18
- private closed;
19
- private remoteBroadcasts;
20
- constructor(options: AutopushOptions);
21
- getUaid(): string;
22
- getEndpoint(): string;
23
- getRemoteBroadcasts(): Record<string, string>;
24
- connect(): Promise<string>;
25
- close(): void;
26
- private send;
27
- private reconnect;
28
- }
29
- //# sourceMappingURL=autopush.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"autopush.d.ts","sourceRoot":"","sources":["../src/autopush.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,SAAS,CAAC;AAIpD,MAAM,WAAW,eAAe;IAC9B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,gBAAgB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC1C,cAAc,EAAE,CAAC,YAAY,EAAE,oBAAoB,KAAK,IAAI,CAAC;IAC7D,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;IACnC,cAAc,CAAC,EAAE,MAAM,IAAI,CAAC;IAC5B,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;CAC1C;AAED,qBAAa,cAAc;IAQb,OAAO,CAAC,OAAO;IAP3B,OAAO,CAAC,EAAE,CAA0B;IACpC,OAAO,CAAC,IAAI,CAAM;IAClB,OAAO,CAAC,QAAQ,CAAM;IACtB,OAAO,CAAC,cAAc,CAAQ;IAC9B,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,gBAAgB,CAAyB;gBAE7B,OAAO,EAAE,eAAe;IAK5C,OAAO,IAAI,MAAM;IAIjB,WAAW,IAAI,MAAM;IAIrB,mBAAmB,IAAI,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC;IAI7C,OAAO,IAAI,OAAO,CAAC,MAAM,CAAC;IAoF1B,KAAK,IAAI,IAAI;IAKb,OAAO,CAAC,IAAI;IAIZ,OAAO,CAAC,SAAS;CASlB"}
package/dist/client.d.ts DELETED
@@ -1,20 +0,0 @@
1
- import { EventEmitter } from "events";
2
- import type { ClientState, NotificationClientOptions, TwitterNotification } from "./types";
3
- interface NotificationClientEvents {
4
- notification: [notification: TwitterNotification];
5
- connected: [state: ClientState];
6
- error: [error: Error];
7
- disconnected: [];
8
- reconnecting: [delay: number];
9
- }
10
- export declare class NotificationClient extends EventEmitter<NotificationClientEvents> {
11
- private autopush;
12
- private running;
13
- private options;
14
- constructor(options: NotificationClientOptions);
15
- start(): Promise<void>;
16
- stop(): void;
17
- }
18
- export declare function createClient(options: NotificationClientOptions): NotificationClient;
19
- export {};
20
- //# sourceMappingURL=client.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,QAAQ,CAAC;AAKtC,OAAO,KAAK,EAEV,WAAW,EACX,yBAAyB,EACzB,mBAAmB,EACpB,MAAM,SAAS,CAAC;AAKjB,UAAU,wBAAwB;IAChC,YAAY,EAAE,CAAC,YAAY,EAAE,mBAAmB,CAAC,CAAC;IAClD,SAAS,EAAE,CAAC,KAAK,EAAE,WAAW,CAAC,CAAC;IAChC,KAAK,EAAE,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;IACtB,YAAY,EAAE,EAAE,CAAC;IACjB,YAAY,EAAE,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC;CAC/B;AAED,qBAAa,kBAAmB,SAAQ,YAAY,CAAC,wBAAwB,CAAC;IAC5E,OAAO,CAAC,QAAQ,CAA+B;IAC/C,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,OAAO,CAA4B;gBAE/B,OAAO,EAAE,yBAAyB;IAKxC,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAoF5B,IAAI,IAAI,IAAI;CAKb;AAED,wBAAgB,YAAY,CAAC,OAAO,EAAE,yBAAyB,GAAG,kBAAkB,CAEnF"}
package/dist/decrypt.d.ts DELETED
@@ -1,13 +0,0 @@
1
- export declare class Decryptor {
2
- private keyPair;
3
- private publicKeyRaw;
4
- private authSecret;
5
- private jwk;
6
- private constructor();
7
- static create(jwk?: JsonWebKey, authBase64url?: string): Promise<Decryptor>;
8
- getJwk(): JsonWebKey;
9
- getAuthBase64url(): string;
10
- getPublicKeyBase64url(): string;
11
- decrypt(cryptoKeyHeader: string, encryptionHeader: string, payload: ArrayBuffer): Promise<string>;
12
- }
13
- //# sourceMappingURL=decrypt.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"decrypt.d.ts","sourceRoot":"","sources":["../src/decrypt.ts"],"names":[],"mappings":"AAEA,qBAAa,SAAS;IAElB,OAAO,CAAC,OAAO;IACf,OAAO,CAAC,YAAY;IACpB,OAAO,CAAC,UAAU;IAClB,OAAO,CAAC,GAAG;IAJb,OAAO;WAOM,MAAM,CAAC,GAAG,CAAC,EAAE,UAAU,EAAE,aAAa,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,CAAC;IAsCjF,MAAM,IAAI,UAAU;IAIpB,gBAAgB,IAAI,MAAM;IAI1B,qBAAqB,IAAI,MAAM;IAIzB,OAAO,CACX,eAAe,EAAE,MAAM,EACvB,gBAAgB,EAAE,MAAM,EACxB,OAAO,EAAE,WAAW,GACnB,OAAO,CAAC,MAAM,CAAC;CAqFnB"}
package/dist/index.d.ts DELETED
@@ -1,6 +0,0 @@
1
- export { NotificationClient, createClient } from "./client";
2
- export { Decryptor } from "./decrypt";
3
- export { AutopushClient } from "./autopush";
4
- export type { AutopushOptions } from "./autopush";
5
- export type { TwitterNotification, ClientState, NotificationClientOptions, AutopushNotification, } from "./types";
6
- //# sourceMappingURL=index.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AAC5D,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AACtC,OAAO,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAC5C,YAAY,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAClD,YAAY,EACV,mBAAmB,EACnB,WAAW,EACX,yBAAyB,EACzB,oBAAoB,GACrB,MAAM,SAAS,CAAC"}
package/dist/index.js DELETED
@@ -1,447 +0,0 @@
1
- // @bun
2
- // src/client.ts
3
- import { EventEmitter } from "events";
4
-
5
- // src/utils.ts
6
- function base64urlToBuffer(b64url) {
7
- const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/");
8
- const padded = b64 + "=".repeat((4 - b64.length % 4) % 4);
9
- const binary = atob(padded);
10
- const bytes = new Uint8Array(binary.length);
11
- for (let i = 0;i < binary.length; i++) {
12
- bytes[i] = binary.charCodeAt(i);
13
- }
14
- return bytes.buffer;
15
- }
16
- function bufferToBase64url(buffer) {
17
- const bytes = new Uint8Array(buffer);
18
- let binary = "";
19
- for (const byte of bytes) {
20
- binary += String.fromCharCode(byte);
21
- }
22
- return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
23
- }
24
- function concatBuffers(...buffers) {
25
- const totalLength = buffers.reduce((acc, buf) => acc + buf.byteLength, 0);
26
- const result = new Uint8Array(totalLength);
27
- let offset = 0;
28
- for (const buf of buffers) {
29
- result.set(new Uint8Array(buf), offset);
30
- offset += buf.byteLength;
31
- }
32
- return result.buffer;
33
- }
34
-
35
- // src/decrypt.ts
36
- class Decryptor {
37
- keyPair;
38
- publicKeyRaw;
39
- authSecret;
40
- jwk;
41
- constructor(keyPair, publicKeyRaw, authSecret, jwk) {
42
- this.keyPair = keyPair;
43
- this.publicKeyRaw = publicKeyRaw;
44
- this.authSecret = authSecret;
45
- this.jwk = jwk;
46
- }
47
- static async create(jwk, authBase64url) {
48
- let keyPair;
49
- let savedJwk;
50
- if (jwk) {
51
- const privateKey = await crypto.subtle.importKey("jwk", jwk, { name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]);
52
- const pubJwk = { ...jwk, d: undefined, key_ops: [] };
53
- delete pubJwk.d;
54
- const publicKey = await crypto.subtle.importKey("jwk", pubJwk, { name: "ECDH", namedCurve: "P-256" }, true, []);
55
- keyPair = { privateKey, publicKey };
56
- savedJwk = jwk;
57
- } else {
58
- keyPair = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, [
59
- "deriveBits"
60
- ]);
61
- savedJwk = await crypto.subtle.exportKey("jwk", keyPair.privateKey);
62
- }
63
- const publicKeyRaw = await crypto.subtle.exportKey("raw", keyPair.publicKey);
64
- const authSecret = authBase64url ? base64urlToBuffer(authBase64url) : crypto.getRandomValues(new Uint8Array(16)).buffer;
65
- return new Decryptor(keyPair, publicKeyRaw, authSecret, savedJwk);
66
- }
67
- getJwk() {
68
- return this.jwk;
69
- }
70
- getAuthBase64url() {
71
- return bufferToBase64url(this.authSecret);
72
- }
73
- getPublicKeyBase64url() {
74
- return bufferToBase64url(this.publicKeyRaw);
75
- }
76
- async decrypt(cryptoKeyHeader, encryptionHeader, payload) {
77
- const cryptoKeyParams = parseHeader(cryptoKeyHeader);
78
- const encryptionParams = parseHeader(encryptionHeader);
79
- const remotePubKeyBytes = base64urlToBuffer(cryptoKeyParams.dh);
80
- const salt = base64urlToBuffer(encryptionParams.salt);
81
- const rs = encryptionParams.rs ? parseInt(encryptionParams.rs) : 0;
82
- const remotePubKey = await crypto.subtle.importKey("raw", remotePubKeyBytes, { name: "ECDH", namedCurve: "P-256" }, true, []);
83
- const sharedSecret = new Uint8Array(await crypto.subtle.deriveBits({ name: "ECDH", public: remotePubKey }, this.keyPair.privateKey, 256));
84
- const authInfo = new TextEncoder().encode("Content-Encoding: auth\x00");
85
- const ikm = await hkdf(new Uint8Array(this.authSecret), sharedSecret, authInfo, 32);
86
- const context = concatBuffers(new TextEncoder().encode("P-256\x00").buffer, new Uint8Array([0, 65]).buffer, this.publicKeyRaw, new Uint8Array([0, 65]).buffer, remotePubKeyBytes);
87
- const cekInfo = concatBuffers(new TextEncoder().encode("Content-Encoding: aesgcm\x00").buffer, context);
88
- const cek = await hkdf(new Uint8Array(salt), ikm, new Uint8Array(cekInfo), 16);
89
- const nonceInfo = concatBuffers(new TextEncoder().encode("Content-Encoding: nonce\x00").buffer, context);
90
- const nonce = await hkdf(new Uint8Array(salt), ikm, new Uint8Array(nonceInfo), 12);
91
- const cekKey = await crypto.subtle.importKey("raw", cek, "AES-GCM", false, ["decrypt"]);
92
- let decrypted;
93
- if (rs >= 18) {
94
- const chunks = splitPayload(payload, rs);
95
- const parts = [];
96
- for (let i = 0;i < chunks.length; i++) {
97
- const chunkNonce = adjustNonce(nonce, i);
98
- const part = await crypto.subtle.decrypt({ name: "AES-GCM", iv: chunkNonce }, cekKey, chunks[i]);
99
- parts.push(part);
100
- }
101
- decrypted = concatBuffers(...parts);
102
- } else {
103
- decrypted = await crypto.subtle.decrypt({ name: "AES-GCM", iv: nonce }, cekKey, payload);
104
- }
105
- const view = new DataView(decrypted);
106
- const paddingLen = view.getUint16(0);
107
- const plaintext = decrypted.slice(2 + paddingLen);
108
- return new TextDecoder().decode(plaintext);
109
- }
110
- }
111
- function parseHeader(header) {
112
- const result = {};
113
- for (const part of header.split(";")) {
114
- const trimmed = part.trim();
115
- const eqIdx = trimmed.indexOf("=");
116
- if (eqIdx !== -1) {
117
- result[trimmed.slice(0, eqIdx).trim()] = trimmed.slice(eqIdx + 1).trim();
118
- }
119
- }
120
- return result;
121
- }
122
- async function hmacSha256(key, data) {
123
- const cryptoKey = await crypto.subtle.importKey("raw", key, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
124
- return new Uint8Array(await crypto.subtle.sign("HMAC", cryptoKey, data));
125
- }
126
- async function hkdf(salt, ikm, info, length) {
127
- const prk = await hmacSha256(salt, ikm);
128
- const infoWithCounter = new Uint8Array(info.length + 1);
129
- infoWithCounter.set(info);
130
- infoWithCounter[info.length] = 1;
131
- const expanded = await hmacSha256(prk, infoWithCounter);
132
- return expanded.slice(0, length);
133
- }
134
- function splitPayload(payload, rs) {
135
- const bytes = new Uint8Array(payload);
136
- const chunks = [];
137
- for (let i = 0;i < bytes.length; i += rs) {
138
- chunks.push(bytes.slice(i, Math.min(i + rs, bytes.length)).buffer);
139
- }
140
- return chunks;
141
- }
142
- function adjustNonce(nonce, offset) {
143
- if (offset === 0)
144
- return nonce;
145
- const adjusted = new Uint8Array(nonce);
146
- for (let i = 11;i >= 6; i--) {
147
- adjusted[i] ^= offset >>> (11 - i) * 8 & 255;
148
- }
149
- return adjusted;
150
- }
151
-
152
- // src/autopush.ts
153
- var AUTOPUSH_URL = "wss://push.services.mozilla.com/";
154
-
155
- class AutopushClient {
156
- options;
157
- ws = null;
158
- uaid = "";
159
- endpoint = "";
160
- reconnectDelay = 1000;
161
- closed = false;
162
- remoteBroadcasts;
163
- constructor(options) {
164
- this.options = options;
165
- if (options.uaid)
166
- this.uaid = options.uaid;
167
- this.remoteBroadcasts = options.remoteBroadcasts || {};
168
- }
169
- getUaid() {
170
- return this.uaid;
171
- }
172
- getEndpoint() {
173
- return this.endpoint;
174
- }
175
- getRemoteBroadcasts() {
176
- return this.remoteBroadcasts;
177
- }
178
- connect() {
179
- return new Promise((resolve, reject) => {
180
- let resolved = false;
181
- this.ws = new WebSocket(AUTOPUSH_URL, ["push-notification"]);
182
- this.ws.onopen = () => {
183
- this.reconnectDelay = 1000;
184
- this.send({
185
- messageType: "hello",
186
- use_webpush: true,
187
- uaid: this.uaid || "",
188
- broadcasts: this.remoteBroadcasts
189
- });
190
- };
191
- this.ws.onmessage = (event) => {
192
- const msg = JSON.parse(String(event.data));
193
- switch (msg.messageType) {
194
- case "hello":
195
- this.uaid = msg.uaid;
196
- if (msg.broadcasts) {
197
- this.remoteBroadcasts = msg.broadcasts;
198
- }
199
- this.send({
200
- channelID: this.options.channelId,
201
- messageType: "register",
202
- key: this.options.vapidKey
203
- });
204
- break;
205
- case "register":
206
- if (msg.status === 200) {
207
- this.endpoint = msg.pushEndpoint;
208
- if (!resolved) {
209
- resolved = true;
210
- resolve(this.endpoint);
211
- }
212
- } else {
213
- const err = new Error(`Register failed: status ${msg.status}`);
214
- if (!resolved) {
215
- resolved = true;
216
- reject(err);
217
- }
218
- }
219
- break;
220
- case "notification": {
221
- const notification = msg;
222
- this.send({
223
- messageType: "ack",
224
- updates: [
225
- {
226
- channelID: notification.channelID,
227
- version: notification.version,
228
- code: 100
229
- }
230
- ]
231
- });
232
- this.options.onNotification(notification);
233
- break;
234
- }
235
- }
236
- };
237
- this.ws.onclose = () => {
238
- if (!this.closed) {
239
- this.options.onDisconnected?.();
240
- this.reconnect();
241
- }
242
- };
243
- this.ws.onerror = (err) => {
244
- this.options.onError?.(err);
245
- if (!resolved) {
246
- resolved = true;
247
- reject(err);
248
- }
249
- };
250
- });
251
- }
252
- close() {
253
- this.closed = true;
254
- this.ws?.close();
255
- }
256
- send(msg) {
257
- this.ws?.send(JSON.stringify(msg));
258
- }
259
- reconnect() {
260
- this.options.onReconnecting?.(this.reconnectDelay);
261
- setTimeout(() => {
262
- this.reconnectDelay = Math.min(this.reconnectDelay * 2, 60000);
263
- this.connect().catch((err) => {
264
- this.options.onError?.(err);
265
- });
266
- }, this.reconnectDelay);
267
- }
268
- }
269
-
270
- // src/twitter.ts
271
- import { TwitterOpenApi } from "twitter-openapi-typescript";
272
- import {
273
- BaseAPI
274
- } from "twitter-openapi-typescript-generated";
275
- var API_HEADER_NAMES = [
276
- "Accept",
277
- "x-twitter-client-language",
278
- "Priority",
279
- "Referer",
280
- "Sec-Fetch-Dest",
281
- "Sec-Ch-Ua-Platform",
282
- "Sec-Fetch-Mode",
283
- "x-csrf-token",
284
- "x-client-uuid",
285
- "x-guest-token",
286
- "Sec-Ch-Ua",
287
- "x-twitter-active-user",
288
- "user-agent",
289
- "Accept-Language",
290
- "Sec-Fetch-Site",
291
- "x-twitter-auth-type",
292
- "Sec-Ch-Ua-Mobile",
293
- "Accept-Encoding"
294
- ];
295
-
296
- class NotificationApi extends BaseAPI {
297
- constructor(config) {
298
- super(config);
299
- }
300
- async post(path, body, initOverride) {
301
- const headers = {};
302
- const apiKey = this.configuration.apiKey;
303
- if (apiKey) {
304
- for (const name of API_HEADER_NAMES) {
305
- const value = await apiKey(name);
306
- if (value)
307
- headers[name] = value;
308
- }
309
- }
310
- const accessToken = this.configuration.accessToken;
311
- if (accessToken) {
312
- headers["Authorization"] = `Bearer ${await accessToken()}`;
313
- }
314
- headers["Content-Type"] = "application/json";
315
- return this.request({ path, method: "POST", headers, body }, initOverride);
316
- }
317
- }
318
- async function createClient(cookies) {
319
- const api = new TwitterOpenApi;
320
- return api.getClientFromCookies(cookies);
321
- }
322
- function makeInitOverride(client, path) {
323
- return client.initOverrides({
324
- "@method": "POST",
325
- "@path": path
326
- });
327
- }
328
- async function registerPush(client, subscription) {
329
- const api = new NotificationApi(client.config);
330
- const path = "/1.1/notifications/settings/login.json";
331
- const res = await api.post(path, {
332
- push_device_info: {
333
- os_version: "Web/Chrome",
334
- udid: "Web/Chrome",
335
- env: 3,
336
- locale: "en",
337
- protocol_version: 1,
338
- token: subscription.endpoint,
339
- encryption_key1: subscription.p256dh,
340
- encryption_key2: subscription.auth
341
- }
342
- }, makeInitOverride(client, path));
343
- if (!res.ok) {
344
- const text = await res.text();
345
- throw new Error(`login.json failed (${res.status}): ${text}`);
346
- }
347
- }
348
-
349
- // src/client.ts
350
- var VAPID_KEY = "BF5oEo0xDUpgylKDTlsd8pZmxQA1leYINiY-rSscWYK_3tWAkz4VMbtf1MLE_Yyd6iII6o-e3Q9TCN5vZMzVMEs";
351
-
352
- class NotificationClient extends EventEmitter {
353
- autopush = null;
354
- running = false;
355
- options;
356
- constructor(options) {
357
- super();
358
- this.options = options;
359
- }
360
- async start() {
361
- if (this.running)
362
- return;
363
- this.running = true;
364
- try {
365
- let decryptor;
366
- let channelId;
367
- let uaid;
368
- let savedEndpoint;
369
- let remoteBroadcasts;
370
- if (this.options.state) {
371
- const s = this.options.state;
372
- decryptor = await Decryptor.create(s.decryptor.jwk, s.decryptor.auth);
373
- channelId = s.channelId;
374
- uaid = s.uaid;
375
- savedEndpoint = s.endpoint;
376
- remoteBroadcasts = s.remoteBroadcasts;
377
- } else {
378
- decryptor = await Decryptor.create();
379
- channelId = crypto.randomUUID();
380
- }
381
- this.autopush = new AutopushClient({
382
- uaid,
383
- channelId,
384
- vapidKey: VAPID_KEY,
385
- remoteBroadcasts,
386
- onNotification: async (msg) => {
387
- try {
388
- const payload = base64urlToBuffer(msg.data);
389
- const json = await decryptor.decrypt(msg.headers.crypto_key, msg.headers.encryption, payload);
390
- const notification = JSON.parse(json);
391
- this.emit("notification", notification);
392
- } catch (err) {
393
- this.emit("error", err instanceof Error ? err : new Error(String(err)));
394
- }
395
- },
396
- onError: (err) => {
397
- this.emit("error", err instanceof Error ? err : new Error(String(err)));
398
- },
399
- onDisconnected: () => {
400
- this.emit("disconnected");
401
- },
402
- onReconnecting: (delay) => {
403
- this.emit("reconnecting", delay);
404
- }
405
- });
406
- const endpoint = await this.autopush.connect();
407
- const needsRegistration = endpoint !== savedEndpoint;
408
- const state = {
409
- uaid: this.autopush.getUaid(),
410
- channelId,
411
- endpoint,
412
- remoteBroadcasts: this.autopush.getRemoteBroadcasts(),
413
- decryptor: {
414
- jwk: decryptor.getJwk(),
415
- auth: decryptor.getAuthBase64url()
416
- }
417
- };
418
- this.emit("connected", state);
419
- if (needsRegistration) {
420
- const twitterClient = await createClient(this.options.cookies);
421
- await registerPush(twitterClient, {
422
- endpoint,
423
- p256dh: decryptor.getPublicKeyBase64url(),
424
- auth: decryptor.getAuthBase64url()
425
- });
426
- }
427
- } catch (err) {
428
- this.running = false;
429
- this.autopush = null;
430
- throw err;
431
- }
432
- }
433
- stop() {
434
- this.running = false;
435
- this.autopush?.close();
436
- this.autopush = null;
437
- }
438
- }
439
- function createClient2(options) {
440
- return new NotificationClient(options);
441
- }
442
- export {
443
- createClient2 as createClient,
444
- NotificationClient,
445
- Decryptor,
446
- AutopushClient
447
- };
package/dist/twitter.d.ts DELETED
@@ -1,9 +0,0 @@
1
- import { type TwitterOpenApiClient } from "twitter-openapi-typescript";
2
- export interface PushSubscription {
3
- endpoint: string;
4
- p256dh: string;
5
- auth: string;
6
- }
7
- export declare function createClient(cookies: Record<string, string>): Promise<TwitterOpenApiClient>;
8
- export declare function registerPush(client: TwitterOpenApiClient, subscription: PushSubscription): Promise<void>;
9
- //# sourceMappingURL=twitter.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"twitter.d.ts","sourceRoot":"","sources":["../src/twitter.ts"],"names":[],"mappings":"AAAA,OAAO,EAAkB,KAAK,oBAAoB,EAAE,MAAM,4BAA4B,CAAC;AASvF,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;CACd;AAsDD,wBAAsB,YAAY,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,OAAO,CAAC,oBAAoB,CAAC,CAGjG;AASD,wBAAsB,YAAY,CAChC,MAAM,EAAE,oBAAoB,EAC5B,YAAY,EAAE,gBAAgB,GAC7B,OAAO,CAAC,IAAI,CAAC,CAyBf"}
package/dist/types.d.ts DELETED
@@ -1,49 +0,0 @@
1
- export interface AutopushNotification {
2
- messageType: "notification";
3
- channelID: string;
4
- version: string;
5
- data: string;
6
- headers: {
7
- crypto_key: string;
8
- encryption: string;
9
- };
10
- }
11
- export interface TwitterNotification {
12
- title: string;
13
- body: string;
14
- icon?: string;
15
- timestamp?: number;
16
- tag?: string;
17
- data?: {
18
- type?: string;
19
- uri?: string;
20
- title?: string;
21
- body?: string;
22
- tag?: string;
23
- lang?: string;
24
- bundle_text?: string;
25
- scribe_target?: string;
26
- impression_id?: string;
27
- [key: string]: unknown;
28
- };
29
- [key: string]: unknown;
30
- }
31
- export interface ClientState {
32
- uaid: string;
33
- channelId: string;
34
- endpoint: string;
35
- remoteBroadcasts: Record<string, string>;
36
- decryptor: {
37
- jwk: JsonWebKey;
38
- auth: string;
39
- };
40
- }
41
- export interface NotificationClientOptions {
42
- cookies: {
43
- auth_token: string;
44
- ct0: string;
45
- [key: string]: string;
46
- };
47
- state?: ClientState;
48
- }
49
- //# sourceMappingURL=types.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,oBAAoB;IACnC,WAAW,EAAE,cAAc,CAAC;IAC5B,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE;QACP,UAAU,EAAE,MAAM,CAAC;QACnB,UAAU,EAAE,MAAM,CAAC;KACpB,CAAC;CACH;AAED,MAAM,WAAW,mBAAmB;IAClC,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE;QACL,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,GAAG,CAAC,EAAE,MAAM,CAAC;QACb,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,GAAG,CAAC,EAAE,MAAM,CAAC;QACb,IAAI,CAAC,EAAE,MAAM,CAAC;QACd,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,aAAa,CAAC,EAAE,MAAM,CAAC;QACvB,aAAa,CAAC,EAAE,MAAM,CAAC;QACvB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;KACxB,CAAC;IACF,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,gBAAgB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACzC,SAAS,EAAE;QACT,GAAG,EAAE,UAAU,CAAC;QAChB,IAAI,EAAE,MAAM,CAAC;KACd,CAAC;CACH;AAED,MAAM,WAAW,yBAAyB;IACxC,OAAO,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAC;QAAC,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAAA;KAAE,CAAC;IACpE,KAAK,CAAC,EAAE,WAAW,CAAC;CACrB"}
package/dist/utils.d.ts DELETED
@@ -1,4 +0,0 @@
1
- export declare function base64urlToBuffer(b64url: string): ArrayBuffer;
2
- export declare function bufferToBase64url(buffer: ArrayBuffer): string;
3
- export declare function concatBuffers(...buffers: ArrayBuffer[]): ArrayBuffer;
4
- //# sourceMappingURL=utils.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"AAAA,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,WAAW,CAS7D;AAED,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,WAAW,GAAG,MAAM,CAO7D;AAED,wBAAgB,aAAa,CAAC,GAAG,OAAO,EAAE,WAAW,EAAE,GAAG,WAAW,CASpE"}