xnotif 0.1.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/dist/autopush.d.ts +29 -0
- package/dist/autopush.d.ts.map +1 -0
- package/dist/client.d.ts +20 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/decrypt.d.ts +13 -0
- package/dist/decrypt.d.ts.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +447 -0
- package/dist/twitter.d.ts +9 -0
- package/dist/twitter.d.ts.map +1 -0
- package/dist/types.d.ts +49 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/utils.d.ts +4 -0
- package/dist/utils.d.ts.map +1 -0
- package/package.json +46 -0
|
@@ -0,0 +1,29 @@
|
|
|
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
|
|
@@ -0,0 +1 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
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
|
|
@@ -0,0 +1 @@
|
|
|
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"}
|
|
@@ -0,0 +1,13 @@
|
|
|
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
|
|
@@ -0,0 +1 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
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
|
|
@@ -0,0 +1 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
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
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
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
|
|
@@ -0,0 +1 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
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
|
|
@@ -0,0 +1 @@
|
|
|
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
ADDED
|
@@ -0,0 +1 @@
|
|
|
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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "xnotif",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Receive Twitter/X push notifications programmatically via Mozilla Autopush",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"autopush",
|
|
7
|
+
"notifications",
|
|
8
|
+
"push",
|
|
9
|
+
"twitter",
|
|
10
|
+
"webpush",
|
|
11
|
+
"x"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://github.com/yutakobayashidev/xnotif",
|
|
14
|
+
"bugs": "https://github.com/yutakobayashidev/xnotif/issues",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"author": "yutakobayashidev",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "https://github.com/yutakobayashidev/xnotif"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist"
|
|
23
|
+
],
|
|
24
|
+
"type": "module",
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"exports": {
|
|
27
|
+
".": {
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"import": "./dist/index.js"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "bun build src/index.ts --outdir dist --target bun --packages external && bunx tsc --emitDeclarationOnly"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"twitter-openapi-typescript": "^0.0.55",
|
|
37
|
+
"twitter-openapi-typescript-generated": "^0.0.38"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/bun": "^1.2.0",
|
|
41
|
+
"typescript": "^5.9.3"
|
|
42
|
+
},
|
|
43
|
+
"engines": {
|
|
44
|
+
"bun": ">=1.0.0"
|
|
45
|
+
}
|
|
46
|
+
}
|