twitchdropsminer-cli 0.1.3 → 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/dist/cli/commands/status.js +20 -2
- package/dist/cli/index.js +4 -1
- package/dist/config/schema.js +5 -1
- package/dist/core/channelService.js +10 -6
- package/dist/core/concurrency.js +22 -0
- package/dist/core/miner.js +22 -6
- package/dist/core/runtime.js +26 -2
- package/dist/integrations/gqlClient.js +42 -2
- package/dist/integrations/gqlOperations.js +8 -0
- package/dist/integrations/httpClient.js +80 -7
- package/dist/integrations/twitchPubSub.js +128 -22
- package/dist/tests/index.js +5 -0
- package/dist/tests/unit/channelServiceConcurrency.test.js +35 -0
- package/dist/tests/unit/concurrency.test.js +21 -0
- package/dist/tests/unit/gqlClient.test.js +14 -0
- package/dist/tests/unit/httpClient.test.js +52 -0
- package/dist/tests/unit/twitchPubSub.test.js +63 -0
- package/docs/ops/authentication.md +32 -32
- package/docs/ops/drops-validation.md +73 -73
- package/docs/ops/linux-install.md +15 -15
- package/docs/ops/service-management.md +23 -23
- package/docs/ops/systemd-hardening.md +13 -13
- package/package.json +1 -1
- package/resources/systemd/tdm.service.tpl +17 -17
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
import { Command } from "@commander-js/extra-typings";
|
|
2
2
|
import { loadSessionState } from "../../state/sessionState.js";
|
|
3
|
+
import { isMinerLockHeldByLiveProcess } from "../../core/runtime.js";
|
|
4
|
+
const SESSION_FRESH_MS = 120_000;
|
|
5
|
+
function sessionImpliesRunning(rawState, updatedAt) {
|
|
6
|
+
if (rawState === "EXIT" || rawState === "UNKNOWN") {
|
|
7
|
+
return false;
|
|
8
|
+
}
|
|
9
|
+
if (!updatedAt) {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
const t = new Date(updatedAt).getTime();
|
|
13
|
+
if (!Number.isFinite(t)) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
return Date.now() - t < SESSION_FRESH_MS;
|
|
17
|
+
}
|
|
3
18
|
export const statusCommand = new Command("status")
|
|
4
19
|
.description("Show current miner status")
|
|
5
20
|
.option("--json", "Output status as JSON")
|
|
@@ -11,8 +26,11 @@ export const statusCommand = new Command("status")
|
|
|
11
26
|
: rawState !== "IDLE" && rawState !== "EXIT"
|
|
12
27
|
? "MAINTENANCE"
|
|
13
28
|
: rawState;
|
|
29
|
+
const lockHeld = isMinerLockHeldByLiveProcess();
|
|
30
|
+
const running = lockHeld || sessionImpliesRunning(rawState, session?.updatedAt);
|
|
14
31
|
const status = {
|
|
15
|
-
running
|
|
32
|
+
running,
|
|
33
|
+
lockHeld,
|
|
16
34
|
state: highLevel,
|
|
17
35
|
rawState,
|
|
18
36
|
watchedChannel: session?.watchedChannelName ?? null,
|
|
@@ -24,6 +42,6 @@ export const statusCommand = new Command("status")
|
|
|
24
42
|
}
|
|
25
43
|
else {
|
|
26
44
|
// eslint-disable-next-line no-console
|
|
27
|
-
console.log(`
|
|
45
|
+
console.log(`Running=${status.running}, lock=${status.lockHeld}, state=${status.state}, channel=${status.watchedChannel ?? "-"}, activeDrop=${status.activeDrop ?? "-"}`);
|
|
28
46
|
}
|
|
29
47
|
});
|
package/dist/cli/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from "@commander-js/extra-typings";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
3
4
|
import { runCommand } from "./commands/run.js";
|
|
4
5
|
import { authCommand } from "./commands/auth.js";
|
|
5
6
|
import { statusCommand } from "./commands/status.js";
|
|
@@ -11,10 +12,12 @@ import { serviceCommand } from "./commands/service.js";
|
|
|
11
12
|
import { logsCommand } from "./commands/logs.js";
|
|
12
13
|
import { ensureSingleInstanceLock } from "../core/runtime.js";
|
|
13
14
|
const program = new Command();
|
|
15
|
+
const require = createRequire(import.meta.url);
|
|
16
|
+
const { version } = require("../../package.json");
|
|
14
17
|
program
|
|
15
18
|
.name("tdm")
|
|
16
19
|
.description("Twitch Drops Miner CLI (headless)")
|
|
17
|
-
.version(
|
|
20
|
+
.version(version);
|
|
18
21
|
program.hook("preAction", (_thisCommand, actionCommand) => {
|
|
19
22
|
if (actionCommand.name() === "run" && !actionCommand.getOptionValue("noLock")) {
|
|
20
23
|
ensureSingleInstanceLock();
|
package/dist/config/schema.js
CHANGED
|
@@ -11,6 +11,10 @@ export const ConfigSchema = z.object({
|
|
|
11
11
|
trayNotifications: z.boolean().default(true),
|
|
12
12
|
enableBadgesEmotes: z.boolean().default(false),
|
|
13
13
|
availableDropsCheck: z.boolean().default(false),
|
|
14
|
-
priorityMode: PriorityModeSchema.default("priority_only")
|
|
14
|
+
priorityMode: PriorityModeSchema.default("priority_only"),
|
|
15
|
+
/** Max parallel GameDirectory GQL fetches when resolving channels (default 4). */
|
|
16
|
+
channelFetchConcurrency: z.number().int().min(1).max(10).default(4),
|
|
17
|
+
/** Override persisted-query sha256 hashes when Twitch rotates them (operationName -> hash). */
|
|
18
|
+
gqlHashOverrides: z.record(z.string(), z.string()).default({})
|
|
15
19
|
});
|
|
16
20
|
export const DEFAULT_CONFIG = ConfigSchema.parse({});
|
|
@@ -2,6 +2,8 @@ import { GQL_OPERATIONS } from "../integrations/gqlOperations.js";
|
|
|
2
2
|
import { gqlRequest } from "../integrations/gqlClient.js";
|
|
3
3
|
import { sortChannelCandidates, canWatchChannel } from "../domain/channel.js";
|
|
4
4
|
import { logger } from "./runtime.js";
|
|
5
|
+
import { mapWithConcurrency } from "./concurrency.js";
|
|
6
|
+
import { loadConfig } from "../config/store.js";
|
|
5
7
|
export const MAX_CHANNELS = 100;
|
|
6
8
|
/**
|
|
7
9
|
* Parse Twitch GQL DirectoryPage_Game response into Channel list.
|
|
@@ -56,10 +58,6 @@ export function getAclChannelIdsFromCampaigns(_campaigns) {
|
|
|
56
58
|
// When GQL provides campaign channel allowlist, add those ids here.
|
|
57
59
|
return ids;
|
|
58
60
|
}
|
|
59
|
-
/**
|
|
60
|
-
* Fetch channels for wanted games via GameDirectory GQL, merge and cap to maxChannels.
|
|
61
|
-
* ACL channels (from campaign allowlist) are marked and preferred in sorting elsewhere.
|
|
62
|
-
*/
|
|
63
61
|
/**
|
|
64
62
|
* Resolve game name to Twitch directory slug (from campaigns when available).
|
|
65
63
|
*/
|
|
@@ -69,16 +67,22 @@ function gameNameToSlug(gameName, campaigns) {
|
|
|
69
67
|
}
|
|
70
68
|
export async function fetchChannelsForWantedGames(token, options) {
|
|
71
69
|
const { wantedGames, campaigns, maxChannels = MAX_CHANNELS } = options;
|
|
70
|
+
const gql = options.gqlRequestImpl ??
|
|
71
|
+
((op, t, v) => gqlRequest(op, t, v));
|
|
72
|
+
const concurrency = options.fetchConcurrency ?? loadConfig().channelFetchConcurrency;
|
|
72
73
|
const aclIds = getAclChannelIdsFromCampaigns(campaigns);
|
|
73
74
|
const byId = new Map();
|
|
74
|
-
|
|
75
|
+
const rows = await mapWithConcurrency(wantedGames, concurrency, async (gameName) => {
|
|
75
76
|
const slug = gameNameToSlug(gameName, campaigns);
|
|
76
|
-
const response = await
|
|
77
|
+
const response = await gql(GQL_OPERATIONS.GameDirectory, token, {
|
|
77
78
|
slug,
|
|
78
79
|
limit: 30,
|
|
79
80
|
sortTypeIsRecency: false,
|
|
80
81
|
includeCostreaming: false
|
|
81
82
|
});
|
|
83
|
+
return { gameName, slug, response };
|
|
84
|
+
});
|
|
85
|
+
for (const { gameName, slug, response } of rows) {
|
|
82
86
|
const resp = response;
|
|
83
87
|
const gqlErrors = resp?.errors;
|
|
84
88
|
if (gqlErrors?.length) {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Map items to promises with at most `concurrency` in flight (results align with input order).
|
|
3
|
+
*/
|
|
4
|
+
export async function mapWithConcurrency(items, concurrency, mapper) {
|
|
5
|
+
if (items.length === 0) {
|
|
6
|
+
return [];
|
|
7
|
+
}
|
|
8
|
+
const limit = Math.max(1, Math.min(concurrency, items.length));
|
|
9
|
+
const results = new Array(items.length);
|
|
10
|
+
let next = 0;
|
|
11
|
+
async function worker() {
|
|
12
|
+
while (true) {
|
|
13
|
+
const i = next++;
|
|
14
|
+
if (i >= items.length) {
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
results[i] = await mapper(items[i], i);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
await Promise.all(Array.from({ length: limit }, () => worker()));
|
|
21
|
+
return results;
|
|
22
|
+
}
|
package/dist/core/miner.js
CHANGED
|
@@ -29,6 +29,10 @@ export class Miner {
|
|
|
29
29
|
spadeUrlCache = new Map();
|
|
30
30
|
pubsub = null;
|
|
31
31
|
dryRun = false;
|
|
32
|
+
signalHandlersAttached = false;
|
|
33
|
+
onShutdownSignal = () => {
|
|
34
|
+
void this.shutdown();
|
|
35
|
+
};
|
|
32
36
|
async run(options) {
|
|
33
37
|
if (this.running) {
|
|
34
38
|
return;
|
|
@@ -96,14 +100,10 @@ export class Miner {
|
|
|
96
100
|
this.state.setState("CHANNELS_CLEANUP");
|
|
97
101
|
}
|
|
98
102
|
});
|
|
99
|
-
|
|
100
|
-
void this.shutdown();
|
|
101
|
-
});
|
|
102
|
-
process.on("SIGTERM", () => {
|
|
103
|
-
void this.shutdown();
|
|
104
|
-
});
|
|
103
|
+
this.attachSignalHandlers();
|
|
105
104
|
}
|
|
106
105
|
async shutdown() {
|
|
106
|
+
this.detachSignalHandlers();
|
|
107
107
|
this.running = false;
|
|
108
108
|
this.watchLoop.stop();
|
|
109
109
|
this.maintenance.stop();
|
|
@@ -120,6 +120,22 @@ export class Miner {
|
|
|
120
120
|
watchedChannelName: this.watchingChannel?.login
|
|
121
121
|
});
|
|
122
122
|
}
|
|
123
|
+
attachSignalHandlers() {
|
|
124
|
+
if (this.signalHandlersAttached) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
this.signalHandlersAttached = true;
|
|
128
|
+
process.on("SIGINT", this.onShutdownSignal);
|
|
129
|
+
process.on("SIGTERM", this.onShutdownSignal);
|
|
130
|
+
}
|
|
131
|
+
detachSignalHandlers() {
|
|
132
|
+
if (!this.signalHandlersAttached) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
process.off("SIGINT", this.onShutdownSignal);
|
|
136
|
+
process.off("SIGTERM", this.onShutdownSignal);
|
|
137
|
+
this.signalHandlersAttached = false;
|
|
138
|
+
}
|
|
123
139
|
async claimEligibleDrops(token) {
|
|
124
140
|
for (const campaign of this.campaigns) {
|
|
125
141
|
for (const drop of campaign.drops) {
|
package/dist/core/runtime.js
CHANGED
|
@@ -6,13 +6,37 @@ export const logger = pino({
|
|
|
6
6
|
level: process.env.TDM_LOG_LEVEL || "info"
|
|
7
7
|
});
|
|
8
8
|
let lockFd = null;
|
|
9
|
-
function
|
|
9
|
+
export function minerLockPath() {
|
|
10
10
|
const dir = path.join(os.homedir(), ".local", "state", "tdm");
|
|
11
11
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
12
12
|
return path.join(dir, "lock.file");
|
|
13
13
|
}
|
|
14
|
+
/** True if lock file exists and the recorded PID is still running (best-effort). */
|
|
15
|
+
export function isMinerLockHeldByLiveProcess() {
|
|
16
|
+
const p = minerLockPath();
|
|
17
|
+
if (!fs.existsSync(p)) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
const raw = fs.readFileSync(p, "utf8").trim();
|
|
22
|
+
const pid = Number(raw);
|
|
23
|
+
if (!Number.isFinite(pid) || pid <= 0) {
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
process.kill(pid, 0);
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
14
38
|
export function ensureSingleInstanceLock() {
|
|
15
|
-
const p =
|
|
39
|
+
const p = minerLockPath();
|
|
16
40
|
try {
|
|
17
41
|
lockFd = fs.openSync(p, "wx", 0o600);
|
|
18
42
|
fs.writeFileSync(lockFd, String(process.pid));
|
|
@@ -1,8 +1,46 @@
|
|
|
1
1
|
import { httpJson } from "./httpClient.js";
|
|
2
2
|
import { TWITCH_GQL_URL, TWITCH_ANDROID_CLIENT_ID, TWITCH_ANDROID_USER_AGENT } from "../core/constants.js";
|
|
3
|
-
import { gqlPayload } from "./gqlOperations.js";
|
|
3
|
+
import { gqlPayload, applyGqlHashOverride } from "./gqlOperations.js";
|
|
4
|
+
import { loadConfig } from "../config/store.js";
|
|
5
|
+
export class GqlPersistedQueryMismatchError extends Error {
|
|
6
|
+
operationName;
|
|
7
|
+
sha256Hash;
|
|
8
|
+
gqlMessages;
|
|
9
|
+
constructor(operationName, sha256Hash, gqlMessages) {
|
|
10
|
+
super(`Twitch GQL persisted query failed for "${operationName}" (sha256=${sha256Hash}). ` +
|
|
11
|
+
`Set "gqlHashOverrides" in config (see tdm config path) with { "${operationName}": "<new_hash>" }. ` +
|
|
12
|
+
`GQL: ${gqlMessages.slice(0, 400)}`);
|
|
13
|
+
this.name = "GqlPersistedQueryMismatchError";
|
|
14
|
+
this.operationName = operationName;
|
|
15
|
+
this.sha256Hash = sha256Hash;
|
|
16
|
+
this.gqlMessages = gqlMessages;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function collectGqlErrorText(payload) {
|
|
20
|
+
const rec = payload;
|
|
21
|
+
if (!rec.errors?.length) {
|
|
22
|
+
return "";
|
|
23
|
+
}
|
|
24
|
+
return rec.errors
|
|
25
|
+
.map((e) => {
|
|
26
|
+
const ext = e.extensions ? JSON.stringify(e.extensions) : "";
|
|
27
|
+
return `${e.message ?? ""} ${ext}`;
|
|
28
|
+
})
|
|
29
|
+
.join(" | ");
|
|
30
|
+
}
|
|
31
|
+
export function assertNoGqlPersistedQueryFailure(operation, payload) {
|
|
32
|
+
const text = collectGqlErrorText(payload);
|
|
33
|
+
if (!text) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (/PersistedQueryNotFound|NotFoundForSha256|persisted query|does not match|Unknown query/i.test(text)) {
|
|
37
|
+
throw new GqlPersistedQueryMismatchError(operation.operationName, operation.sha256Hash, text);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
4
40
|
export async function gqlRequest(operation, accessToken, variables) {
|
|
5
|
-
|
|
41
|
+
const cfg = loadConfig();
|
|
42
|
+
const resolved = applyGqlHashOverride(operation, cfg.gqlHashOverrides);
|
|
43
|
+
const payload = await httpJson("POST", TWITCH_GQL_URL, gqlPayload(resolved, variables), {
|
|
6
44
|
retries: 3,
|
|
7
45
|
headers: {
|
|
8
46
|
"Client-Id": TWITCH_ANDROID_CLIENT_ID,
|
|
@@ -10,4 +48,6 @@ export async function gqlRequest(operation, accessToken, variables) {
|
|
|
10
48
|
Authorization: `OAuth ${accessToken}`
|
|
11
49
|
}
|
|
12
50
|
});
|
|
51
|
+
assertNoGqlPersistedQueryFailure(resolved, payload);
|
|
52
|
+
return payload;
|
|
13
53
|
}
|
|
@@ -40,3 +40,11 @@ export function gqlPayload(operation, variables) {
|
|
|
40
40
|
}
|
|
41
41
|
};
|
|
42
42
|
}
|
|
43
|
+
/** Apply config overrides for persisted-query hashes (keyed by operationName). */
|
|
44
|
+
export function applyGqlHashOverride(operation, overrides) {
|
|
45
|
+
const h = overrides[operation.operationName];
|
|
46
|
+
if (!h) {
|
|
47
|
+
return operation;
|
|
48
|
+
}
|
|
49
|
+
return { ...operation, sha256Hash: h };
|
|
50
|
+
}
|
|
@@ -1,36 +1,109 @@
|
|
|
1
1
|
import { request } from "undici";
|
|
2
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
2
3
|
function sleep(ms) {
|
|
3
4
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
4
5
|
}
|
|
6
|
+
function jitterMs(base) {
|
|
7
|
+
return base + Math.floor(Math.random() * 0.25 * base);
|
|
8
|
+
}
|
|
9
|
+
function backoffMs(attempt, baseDelayMs) {
|
|
10
|
+
const exp = baseDelayMs * 2 ** Math.min(attempt, 8);
|
|
11
|
+
const capped = Math.min(60_000, exp);
|
|
12
|
+
return jitterMs(capped);
|
|
13
|
+
}
|
|
14
|
+
/** Parse Twitch/HTTP Retry-After header value: seconds or HTTP-date. */
|
|
15
|
+
export function parseRetryAfterMsFromValue(raw) {
|
|
16
|
+
if (!raw) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
const trimmed = raw.trim();
|
|
20
|
+
const asNum = Number(trimmed);
|
|
21
|
+
if (!Number.isNaN(asNum) && asNum >= 0) {
|
|
22
|
+
return asNum * 1000;
|
|
23
|
+
}
|
|
24
|
+
const when = Date.parse(trimmed);
|
|
25
|
+
if (!Number.isNaN(when)) {
|
|
26
|
+
return Math.max(0, when - Date.now());
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
/** Parse Retry-After from Fetch-style headers. */
|
|
31
|
+
export function parseRetryAfterMs(headers) {
|
|
32
|
+
return parseRetryAfterMsFromValue(headers.get("retry-after"));
|
|
33
|
+
}
|
|
34
|
+
function retryAfterFromUndiciHeaders(headers) {
|
|
35
|
+
const h = headers;
|
|
36
|
+
if (typeof h.get === "function") {
|
|
37
|
+
return parseRetryAfterMsFromValue(h.get("retry-after"));
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
function isAbortError(err) {
|
|
42
|
+
return (err instanceof Error &&
|
|
43
|
+
(err.name === "AbortError" || err.message.includes("aborted") || err.message.includes("timeout")));
|
|
44
|
+
}
|
|
45
|
+
export class HttpResponseError extends Error {
|
|
46
|
+
statusCode;
|
|
47
|
+
bodySnippet;
|
|
48
|
+
constructor(statusCode, bodySnippet) {
|
|
49
|
+
super(`HTTP ${statusCode}: ${bodySnippet.slice(0, 500)}`);
|
|
50
|
+
this.name = "HttpResponseError";
|
|
51
|
+
this.statusCode = statusCode;
|
|
52
|
+
this.bodySnippet = bodySnippet;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
5
55
|
export async function httpJson(method, url, body, options) {
|
|
6
56
|
const retries = options?.retries ?? 3;
|
|
7
57
|
const retryDelayMs = options?.retryDelayMs ?? 1_000;
|
|
58
|
+
const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
8
59
|
let lastError;
|
|
9
60
|
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
|
10
61
|
try {
|
|
62
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
11
63
|
const response = await request(url, {
|
|
12
64
|
method: method,
|
|
13
65
|
headers: {
|
|
14
66
|
"content-type": "application/json",
|
|
15
67
|
...(options?.headers ?? {})
|
|
16
68
|
},
|
|
17
|
-
body: body !== undefined ? JSON.stringify(body) : undefined
|
|
69
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
70
|
+
signal
|
|
18
71
|
});
|
|
19
72
|
const text = await response.body.text();
|
|
20
|
-
if (response.statusCode >=
|
|
21
|
-
|
|
73
|
+
if (response.statusCode >= 200 && response.statusCode < 300) {
|
|
74
|
+
if (!text) {
|
|
75
|
+
return {};
|
|
76
|
+
}
|
|
77
|
+
return JSON.parse(text);
|
|
22
78
|
}
|
|
23
|
-
if (
|
|
24
|
-
|
|
79
|
+
if (response.statusCode === 429 || response.statusCode >= 500) {
|
|
80
|
+
lastError = new HttpResponseError(response.statusCode, text);
|
|
81
|
+
if (attempt === retries) {
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
const fromHeader = retryAfterFromUndiciHeaders(response.headers);
|
|
85
|
+
const waitMs = fromHeader !== null ? fromHeader : backoffMs(attempt, retryDelayMs);
|
|
86
|
+
await sleep(waitMs);
|
|
87
|
+
continue;
|
|
25
88
|
}
|
|
26
|
-
|
|
89
|
+
throw new HttpResponseError(response.statusCode, text);
|
|
27
90
|
}
|
|
28
91
|
catch (err) {
|
|
29
92
|
lastError = err;
|
|
93
|
+
if (err instanceof HttpResponseError) {
|
|
94
|
+
throw err;
|
|
95
|
+
}
|
|
30
96
|
if (attempt === retries) {
|
|
31
97
|
break;
|
|
32
98
|
}
|
|
33
|
-
|
|
99
|
+
const retryable = isAbortError(err) ||
|
|
100
|
+
err instanceof TypeError ||
|
|
101
|
+
(err instanceof Error &&
|
|
102
|
+
/ECONNRESET|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|socket/i.test(err.message));
|
|
103
|
+
if (!retryable) {
|
|
104
|
+
throw err;
|
|
105
|
+
}
|
|
106
|
+
await sleep(backoffMs(attempt, retryDelayMs));
|
|
34
107
|
}
|
|
35
108
|
}
|
|
36
109
|
throw lastError instanceof Error ? lastError : new Error("HTTP request failed.");
|
|
@@ -1,29 +1,40 @@
|
|
|
1
1
|
import WebSocket from "ws";
|
|
2
|
-
import { PING_INTERVAL_MS, TWITCH_PUBSUB_URL, WS_TOPICS_LIMIT } from "../core/constants.js";
|
|
2
|
+
import { PING_INTERVAL_MS, PING_TIMEOUT_MS, TWITCH_PUBSUB_URL, WS_TOPICS_LIMIT } from "../core/constants.js";
|
|
3
|
+
const RECONNECT_BASE_MS = 1_000;
|
|
4
|
+
const RECONNECT_MAX_MS = 60_000;
|
|
5
|
+
function reconnectDelayMs(attempt) {
|
|
6
|
+
const exp = Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** Math.min(attempt, 10));
|
|
7
|
+
const jitter = Math.floor(Math.random() * 0.25 * exp);
|
|
8
|
+
return exp + jitter;
|
|
9
|
+
}
|
|
3
10
|
export class TwitchPubSub {
|
|
4
11
|
ws = null;
|
|
5
12
|
pingTimer = null;
|
|
13
|
+
pongWatchTimer = null;
|
|
14
|
+
reconnectTimer = null;
|
|
6
15
|
handlers = new Map();
|
|
7
16
|
subscribedTopics = new Set();
|
|
8
17
|
authToken = null;
|
|
18
|
+
stopped = false;
|
|
19
|
+
reconnectAttempt = 0;
|
|
20
|
+
createWs;
|
|
21
|
+
constructor(options) {
|
|
22
|
+
this.createWs = options?.createWebSocket ?? ((url) => new WebSocket(url));
|
|
23
|
+
}
|
|
9
24
|
async start() {
|
|
25
|
+
if (this.stopped) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
10
28
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
11
29
|
return;
|
|
12
30
|
}
|
|
13
|
-
await
|
|
14
|
-
const ws = new WebSocket(TWITCH_PUBSUB_URL);
|
|
15
|
-
this.ws = ws;
|
|
16
|
-
ws.on("open", () => {
|
|
17
|
-
this.startPing();
|
|
18
|
-
resolve();
|
|
19
|
-
});
|
|
20
|
-
ws.on("error", (err) => reject(err));
|
|
21
|
-
ws.on("message", (data) => this.onMessage(data.toString()));
|
|
22
|
-
ws.on("close", () => this.stopPing());
|
|
23
|
-
});
|
|
31
|
+
await this.connectOnce();
|
|
24
32
|
}
|
|
25
33
|
async stop() {
|
|
34
|
+
this.stopped = true;
|
|
35
|
+
this.clearReconnectTimer();
|
|
26
36
|
this.stopPing();
|
|
37
|
+
this.clearPongWatch();
|
|
27
38
|
this.subscribedTopics.clear();
|
|
28
39
|
this.authToken = null;
|
|
29
40
|
const ws = this.ws;
|
|
@@ -41,9 +52,6 @@ export class TwitchPubSub {
|
|
|
41
52
|
}
|
|
42
53
|
/** Subscribe to topics; batches and enforces WS_TOPICS_LIMIT. Stores token for reconnect. */
|
|
43
54
|
listen(topics, authToken) {
|
|
44
|
-
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
45
|
-
throw new Error("PubSub socket is not connected.");
|
|
46
|
-
}
|
|
47
55
|
this.authToken = authToken;
|
|
48
56
|
const toAdd = topics.filter((t) => !this.subscribedTopics.has(t));
|
|
49
57
|
if (toAdd.length === 0)
|
|
@@ -55,22 +63,19 @@ export class TwitchPubSub {
|
|
|
55
63
|
for (const t of batch) {
|
|
56
64
|
this.subscribedTopics.add(t);
|
|
57
65
|
}
|
|
58
|
-
this.
|
|
59
|
-
type: "LISTEN",
|
|
60
|
-
data: { topics: batch, auth_token: authToken }
|
|
61
|
-
}));
|
|
66
|
+
this.sendListenBatch(batch, authToken);
|
|
62
67
|
}
|
|
63
68
|
/** Unsubscribe from topics and send UNLISTEN. */
|
|
64
69
|
unlisten(topics, authToken) {
|
|
65
|
-
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
66
|
-
return;
|
|
67
|
-
}
|
|
68
70
|
const toRemove = topics.filter((t) => this.subscribedTopics.has(t));
|
|
69
71
|
if (toRemove.length === 0)
|
|
70
72
|
return;
|
|
71
73
|
for (const t of toRemove) {
|
|
72
74
|
this.subscribedTopics.delete(t);
|
|
73
75
|
}
|
|
76
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
74
79
|
this.ws.send(JSON.stringify({
|
|
75
80
|
type: "UNLISTEN",
|
|
76
81
|
data: { topics: toRemove, auth_token: authToken }
|
|
@@ -79,10 +84,89 @@ export class TwitchPubSub {
|
|
|
79
84
|
getSubscribedTopics() {
|
|
80
85
|
return Array.from(this.subscribedTopics);
|
|
81
86
|
}
|
|
87
|
+
clearReconnectTimer() {
|
|
88
|
+
if (this.reconnectTimer) {
|
|
89
|
+
clearTimeout(this.reconnectTimer);
|
|
90
|
+
this.reconnectTimer = null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
scheduleReconnect() {
|
|
94
|
+
if (this.stopped || this.reconnectTimer) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const delay = reconnectDelayMs(this.reconnectAttempt);
|
|
98
|
+
this.reconnectAttempt += 1;
|
|
99
|
+
this.reconnectTimer = setTimeout(() => {
|
|
100
|
+
this.reconnectTimer = null;
|
|
101
|
+
void this.connectOnce().catch(() => {
|
|
102
|
+
if (!this.stopped) {
|
|
103
|
+
this.scheduleReconnect();
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
}, delay);
|
|
107
|
+
}
|
|
108
|
+
async connectOnce() {
|
|
109
|
+
if (this.stopped) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
return new Promise((resolve, reject) => {
|
|
113
|
+
const ws = this.createWs(TWITCH_PUBSUB_URL);
|
|
114
|
+
this.ws = ws;
|
|
115
|
+
let opened = false;
|
|
116
|
+
const onOpen = () => {
|
|
117
|
+
opened = true;
|
|
118
|
+
this.reconnectAttempt = 0;
|
|
119
|
+
this.resubscribeAll();
|
|
120
|
+
this.startPing();
|
|
121
|
+
ws.off("error", onError);
|
|
122
|
+
resolve();
|
|
123
|
+
};
|
|
124
|
+
const onError = (err) => {
|
|
125
|
+
ws.off("open", onOpen);
|
|
126
|
+
reject(err);
|
|
127
|
+
};
|
|
128
|
+
ws.once("open", onOpen);
|
|
129
|
+
ws.once("error", onError);
|
|
130
|
+
ws.on("message", (data) => {
|
|
131
|
+
void this.onMessage(data.toString());
|
|
132
|
+
});
|
|
133
|
+
ws.on("close", () => {
|
|
134
|
+
this.stopPing();
|
|
135
|
+
this.clearPongWatch();
|
|
136
|
+
const wasCurrent = this.ws === ws;
|
|
137
|
+
if (wasCurrent) {
|
|
138
|
+
this.ws = null;
|
|
139
|
+
}
|
|
140
|
+
if (!this.stopped && wasCurrent && opened) {
|
|
141
|
+
this.scheduleReconnect();
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
resubscribeAll() {
|
|
147
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN || !this.authToken) {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const topics = Array.from(this.subscribedTopics);
|
|
151
|
+
for (let i = 0; i < topics.length; i += WS_TOPICS_LIMIT) {
|
|
152
|
+
const batch = topics.slice(i, i + WS_TOPICS_LIMIT);
|
|
153
|
+
this.sendListenBatch(batch, this.authToken);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
sendListenBatch(batch, authToken) {
|
|
157
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
this.ws.send(JSON.stringify({
|
|
161
|
+
type: "LISTEN",
|
|
162
|
+
data: { topics: batch, auth_token: authToken }
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
82
165
|
startPing() {
|
|
83
166
|
this.stopPing();
|
|
84
167
|
this.pingTimer = setInterval(() => {
|
|
85
168
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
169
|
+
this.armPongWatch();
|
|
86
170
|
this.ws.send(JSON.stringify({ type: "PING" }));
|
|
87
171
|
}
|
|
88
172
|
}, PING_INTERVAL_MS);
|
|
@@ -93,6 +177,27 @@ export class TwitchPubSub {
|
|
|
93
177
|
this.pingTimer = null;
|
|
94
178
|
}
|
|
95
179
|
}
|
|
180
|
+
armPongWatch() {
|
|
181
|
+
this.clearPongWatch();
|
|
182
|
+
this.pongWatchTimer = setTimeout(() => {
|
|
183
|
+
this.pongWatchTimer = null;
|
|
184
|
+
const ws = this.ws;
|
|
185
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
186
|
+
try {
|
|
187
|
+
ws.terminate();
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
// ignore
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}, PING_TIMEOUT_MS);
|
|
194
|
+
}
|
|
195
|
+
clearPongWatch() {
|
|
196
|
+
if (this.pongWatchTimer) {
|
|
197
|
+
clearTimeout(this.pongWatchTimer);
|
|
198
|
+
this.pongWatchTimer = null;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
96
201
|
async onMessage(raw) {
|
|
97
202
|
let parsed;
|
|
98
203
|
try {
|
|
@@ -102,6 +207,7 @@ export class TwitchPubSub {
|
|
|
102
207
|
return;
|
|
103
208
|
}
|
|
104
209
|
if (parsed.type === "PONG") {
|
|
210
|
+
this.clearPongWatch();
|
|
105
211
|
return;
|
|
106
212
|
}
|
|
107
213
|
if (parsed.type === "MESSAGE" && typeof parsed.data === "object" && parsed.data) {
|
package/dist/tests/index.js
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import "./unit/tokenImport.test.js";
|
|
2
2
|
import "./unit/channel.test.js";
|
|
3
3
|
import "./unit/channelService.test.js";
|
|
4
|
+
import "./unit/channelServiceConcurrency.test.js";
|
|
5
|
+
import "./unit/concurrency.test.js";
|
|
4
6
|
import "./unit/dropsDomain.test.js";
|
|
7
|
+
import "./unit/gqlClient.test.js";
|
|
8
|
+
import "./unit/httpClient.test.js";
|
|
9
|
+
import "./unit/twitchPubSub.test.js";
|
|
5
10
|
import "./unit/twitchSpade.test.js";
|
|
6
11
|
import "./integration/configStore.test.js";
|
|
7
12
|
import "./parity/stateMachineFlow.test.js";
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { fetchChannelsForWantedGames } from "../../core/channelService.js";
|
|
4
|
+
import { GQL_OPERATIONS } from "../../integrations/gqlOperations.js";
|
|
5
|
+
test("fetchChannelsForWantedGames respects fetchConcurrency", async () => {
|
|
6
|
+
let maxParallel = 0;
|
|
7
|
+
let current = 0;
|
|
8
|
+
const games = ["A", "B", "C", "D", "E", "F"];
|
|
9
|
+
await fetchChannelsForWantedGames("fake-token", {
|
|
10
|
+
wantedGames: games,
|
|
11
|
+
campaigns: [],
|
|
12
|
+
fetchConcurrency: 2,
|
|
13
|
+
gqlRequestImpl: async (_op, _token, _vars) => {
|
|
14
|
+
current += 1;
|
|
15
|
+
maxParallel = Math.max(maxParallel, current);
|
|
16
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
17
|
+
current -= 1;
|
|
18
|
+
return { data: { game: { streams: { edges: [] } } } };
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
assert.equal(maxParallel, 2);
|
|
22
|
+
});
|
|
23
|
+
test("fetchChannelsForWantedGames uses GameDirectory operation", async () => {
|
|
24
|
+
let seenOp;
|
|
25
|
+
await fetchChannelsForWantedGames("t", {
|
|
26
|
+
wantedGames: ["X"],
|
|
27
|
+
campaigns: [],
|
|
28
|
+
fetchConcurrency: 1,
|
|
29
|
+
gqlRequestImpl: async (op) => {
|
|
30
|
+
seenOp = op;
|
|
31
|
+
return { data: { game: { streams: { edges: [] } } } };
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
assert.equal(seenOp.operationName, GQL_OPERATIONS.GameDirectory.operationName);
|
|
35
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mapWithConcurrency } from "../../core/concurrency.js";
|
|
4
|
+
test("mapWithConcurrency limits parallel execution", async () => {
|
|
5
|
+
let inFlight = 0;
|
|
6
|
+
let maxInFlight = 0;
|
|
7
|
+
const items = [1, 2, 3, 4, 5, 6];
|
|
8
|
+
const results = await mapWithConcurrency(items, 2, async (n) => {
|
|
9
|
+
inFlight += 1;
|
|
10
|
+
maxInFlight = Math.max(maxInFlight, inFlight);
|
|
11
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
12
|
+
inFlight -= 1;
|
|
13
|
+
return n * 2;
|
|
14
|
+
});
|
|
15
|
+
assert.deepEqual(results, [2, 4, 6, 8, 10, 12]);
|
|
16
|
+
assert.equal(maxInFlight, 2);
|
|
17
|
+
});
|
|
18
|
+
test("mapWithConcurrency preserves order", async () => {
|
|
19
|
+
const out = await mapWithConcurrency(["a", "b", "c"], 3, async (x) => `${x}1`);
|
|
20
|
+
assert.deepEqual(out, ["a1", "b1", "c1"]);
|
|
21
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { assertNoGqlPersistedQueryFailure, GqlPersistedQueryMismatchError } from "../../integrations/gqlClient.js";
|
|
4
|
+
import { GQL_OPERATIONS } from "../../integrations/gqlOperations.js";
|
|
5
|
+
test("assertNoGqlPersistedQueryFailure ignores empty errors", () => {
|
|
6
|
+
assertNoGqlPersistedQueryFailure(GQL_OPERATIONS.Inventory, { data: {} });
|
|
7
|
+
});
|
|
8
|
+
test("assertNoGqlPersistedQueryFailure throws on persisted query mismatch", () => {
|
|
9
|
+
assert.throws(() => assertNoGqlPersistedQueryFailure(GQL_OPERATIONS.Inventory, {
|
|
10
|
+
errors: [{ message: "PersistedQueryNotFound" }]
|
|
11
|
+
}), (e) => e instanceof GqlPersistedQueryMismatchError &&
|
|
12
|
+
e.operationName === "Inventory" &&
|
|
13
|
+
e.sha256Hash === GQL_OPERATIONS.Inventory.sha256Hash);
|
|
14
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { httpJson, parseRetryAfterMs, parseRetryAfterMsFromValue, HttpResponseError } from "../../integrations/httpClient.js";
|
|
5
|
+
test("parseRetryAfterMsFromValue parses delay-seconds", () => {
|
|
6
|
+
assert.equal(parseRetryAfterMsFromValue("5"), 5000);
|
|
7
|
+
assert.equal(parseRetryAfterMsFromValue("0"), 0);
|
|
8
|
+
});
|
|
9
|
+
test("parseRetryAfterMs reads header object", () => {
|
|
10
|
+
assert.equal(parseRetryAfterMs({ get: () => "2" }), 2000);
|
|
11
|
+
assert.equal(parseRetryAfterMs({ get: () => null }), null);
|
|
12
|
+
});
|
|
13
|
+
test("httpJson retries on 429 then succeeds", async () => {
|
|
14
|
+
let hits = 0;
|
|
15
|
+
const server = createServer((req, res) => {
|
|
16
|
+
hits += 1;
|
|
17
|
+
if (hits === 1) {
|
|
18
|
+
res.writeHead(429, { "Retry-After": "0" });
|
|
19
|
+
res.end("{}");
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
23
|
+
res.end(JSON.stringify({ ok: true }));
|
|
24
|
+
});
|
|
25
|
+
await new Promise((resolve) => server.listen(0, resolve));
|
|
26
|
+
const { port } = server.address();
|
|
27
|
+
try {
|
|
28
|
+
const out = await httpJson("GET", `http://127.0.0.1:${port}/`, undefined, { retries: 2, retryDelayMs: 10, timeoutMs: 5000 });
|
|
29
|
+
assert.equal(out.ok, true);
|
|
30
|
+
assert.equal(hits, 2);
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
await new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve())));
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
test("httpJson fails fast on non-retryable 4xx", async () => {
|
|
37
|
+
const server = createServer((_req, res) => {
|
|
38
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
39
|
+
res.end(JSON.stringify({ error: "nope" }));
|
|
40
|
+
});
|
|
41
|
+
await new Promise((resolve) => server.listen(0, resolve));
|
|
42
|
+
const { port } = server.address();
|
|
43
|
+
try {
|
|
44
|
+
await assert.rejects(() => httpJson("GET", `http://127.0.0.1:${port}/`, undefined, {
|
|
45
|
+
retries: 3,
|
|
46
|
+
timeoutMs: 5000
|
|
47
|
+
}), (e) => e instanceof HttpResponseError && e.statusCode === 404);
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
await new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve())));
|
|
51
|
+
}
|
|
52
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { EventEmitter } from "node:events";
|
|
4
|
+
import WebSocket from "ws";
|
|
5
|
+
import { TwitchPubSub } from "../../integrations/twitchPubSub.js";
|
|
6
|
+
/** Minimal socket stub matching how TwitchPubSub uses `ws`. */
|
|
7
|
+
class FakeSocket extends EventEmitter {
|
|
8
|
+
static OPEN = WebSocket.OPEN;
|
|
9
|
+
readyState = WebSocket.CONNECTING;
|
|
10
|
+
sent = [];
|
|
11
|
+
send(data) {
|
|
12
|
+
this.sent.push(data);
|
|
13
|
+
}
|
|
14
|
+
openNow() {
|
|
15
|
+
this.readyState = WebSocket.OPEN;
|
|
16
|
+
this.emit("open");
|
|
17
|
+
}
|
|
18
|
+
close() {
|
|
19
|
+
this.readyState = WebSocket.CLOSED;
|
|
20
|
+
this.emit("close");
|
|
21
|
+
}
|
|
22
|
+
terminate() {
|
|
23
|
+
this.close();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
test("TwitchPubSub listen sends LISTEN after open", async () => {
|
|
27
|
+
let sock;
|
|
28
|
+
const pubsub = new TwitchPubSub({
|
|
29
|
+
createWebSocket: () => {
|
|
30
|
+
sock = new FakeSocket();
|
|
31
|
+
queueMicrotask(() => sock.openNow());
|
|
32
|
+
return sock;
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
await pubsub.start();
|
|
36
|
+
pubsub.listen(["user-drop-events.99"], "tok");
|
|
37
|
+
const listenMsg = sock.sent.find((s) => s.includes('"LISTEN"'));
|
|
38
|
+
assert.ok(listenMsg);
|
|
39
|
+
assert.ok(listenMsg.includes("user-drop-events.99"));
|
|
40
|
+
assert.ok(listenMsg.includes("tok"));
|
|
41
|
+
await pubsub.stop();
|
|
42
|
+
});
|
|
43
|
+
test("TwitchPubSub reconnect resubscribes topics", async () => {
|
|
44
|
+
const sockets = [];
|
|
45
|
+
const pubsub = new TwitchPubSub({
|
|
46
|
+
createWebSocket: () => {
|
|
47
|
+
const s = new FakeSocket();
|
|
48
|
+
sockets.push(s);
|
|
49
|
+
queueMicrotask(() => s.openNow());
|
|
50
|
+
return s;
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
await pubsub.start();
|
|
54
|
+
pubsub.listen(["topic.one"], "token1");
|
|
55
|
+
const first = sockets[0];
|
|
56
|
+
assert.ok(first.sent.some((x) => x.includes("topic.one")));
|
|
57
|
+
first.close();
|
|
58
|
+
await new Promise((r) => setTimeout(r, 2500));
|
|
59
|
+
assert.ok(sockets.length >= 2, "expected second socket after reconnect");
|
|
60
|
+
const second = sockets[sockets.length - 1];
|
|
61
|
+
assert.ok(second.sent.some((x) => x.includes("topic.one")));
|
|
62
|
+
await pubsub.stop();
|
|
63
|
+
});
|
|
@@ -1,32 +1,32 @@
|
|
|
1
|
-
## Authentication (headless)
|
|
2
|
-
|
|
3
|
-
### Device-code login (recommended)
|
|
4
|
-
|
|
5
|
-
```bash
|
|
6
|
-
tdm auth login --no-open
|
|
7
|
-
```
|
|
8
|
-
|
|
9
|
-
Follow the printed `verification_uri` and `user_code` on another device.
|
|
10
|
-
|
|
11
|
-
### Import an existing token
|
|
12
|
-
|
|
13
|
-
```bash
|
|
14
|
-
tdm auth import --token "auth-token=XXXX"
|
|
15
|
-
# or
|
|
16
|
-
tdm auth import --token-file /secure/path/token.txt
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
### Import cookies
|
|
20
|
-
|
|
21
|
-
```bash
|
|
22
|
-
tdm auth import-cookie --cookie "auth-token=XXXX; other=YYY"
|
|
23
|
-
# or
|
|
24
|
-
tdm auth import-cookie --cookie-file /secure/path/cookies.txt
|
|
25
|
-
```
|
|
26
|
-
|
|
27
|
-
### Validate
|
|
28
|
-
|
|
29
|
-
```bash
|
|
30
|
-
tdm auth validate
|
|
31
|
-
```
|
|
32
|
-
|
|
1
|
+
## Authentication (headless)
|
|
2
|
+
|
|
3
|
+
### Device-code login (recommended)
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
tdm auth login --no-open
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Follow the printed `verification_uri` and `user_code` on another device.
|
|
10
|
+
|
|
11
|
+
### Import an existing token
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
tdm auth import --token "auth-token=XXXX"
|
|
15
|
+
# or
|
|
16
|
+
tdm auth import --token-file /secure/path/token.txt
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
### Import cookies
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
tdm auth import-cookie --cookie "auth-token=XXXX; other=YYY"
|
|
23
|
+
# or
|
|
24
|
+
tdm auth import-cookie --cookie-file /secure/path/cookies.txt
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### Validate
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
tdm auth validate
|
|
31
|
+
```
|
|
32
|
+
|
|
@@ -1,73 +1,73 @@
|
|
|
1
|
-
# Drops validation playbook
|
|
2
|
-
|
|
3
|
-
This playbook helps confirm that the CLI actually advances and claims Twitch Drops as intended, without opening a browser stream.
|
|
4
|
-
|
|
5
|
-
## Prerequisites
|
|
6
|
-
|
|
7
|
-
- A Twitch account (test account recommended).
|
|
8
|
-
- CLI installed and built: `npm run build`.
|
|
9
|
-
- Auth completed: `tdm auth login --no-open` (or paste token when prompted).
|
|
10
|
-
|
|
11
|
-
## 1. Configure for a single game
|
|
12
|
-
|
|
13
|
-
- Choose an active Drops campaign with a **short first drop** (e.g. 15–30 minutes) so you can see progress quickly.
|
|
14
|
-
- In config (e.g. `~/.config/tdm/config.json` or project `tdm.config.json`), set:
|
|
15
|
-
- `priority`: `["<Game Name>"]` (exact game name from the campaign).
|
|
16
|
-
- `priorityMode`: `"priority_only"` so only that game is mined.
|
|
17
|
-
- `exclude`: `[]` (or leave default).
|
|
18
|
-
|
|
19
|
-
## 2. Dry run (no network writes)
|
|
20
|
-
|
|
21
|
-
- Run with dry-run and verbose to see intended actions only:
|
|
22
|
-
```bash
|
|
23
|
-
tdm run --dry-run --verbose
|
|
24
|
-
```
|
|
25
|
-
- Confirm logs show:
|
|
26
|
-
- Inventory fetch and campaign list.
|
|
27
|
-
- Wanted games = your priority game.
|
|
28
|
-
- Channel fetch and selected channel.
|
|
29
|
-
- “Would send watch” (no real spade POST).
|
|
30
|
-
- “Would claim” for any claimable drop (no real ClaimDrop GQL).
|
|
31
|
-
- Stop with Ctrl+C.
|
|
32
|
-
|
|
33
|
-
## 3. Live run and Twitch Inventory check
|
|
34
|
-
|
|
35
|
-
- In a browser, open [Twitch Drops Inventory](https://www.twitch.tv/drops/inventory) and log in with the same account.
|
|
36
|
-
- Note the current “minutes watched” (and “Claim” button if the drop is ready) for the target campaign.
|
|
37
|
-
- Start the miner:
|
|
38
|
-
```bash
|
|
39
|
-
tdm run --verbose
|
|
40
|
-
```
|
|
41
|
-
- Let it run for at least one watch interval (about 1 minute). You should see “Watch tick sent for channel …” in the logs.
|
|
42
|
-
- Refresh the Twitch Inventory page: “minutes watched” for the active drop should increase (may take 1–2 minutes to reflect).
|
|
43
|
-
- If the drop becomes claimable, the CLI should auto-claim; check logs for “Claimed drop” and confirm the drop shows as claimed in the Inventory.
|
|
44
|
-
|
|
45
|
-
## 4. Compare with Python miner (optional)
|
|
46
|
-
|
|
47
|
-
- Using the same Twitch account and same priority game:
|
|
48
|
-
- Run the Python TwitchDropsMiner and note progression/claim time.
|
|
49
|
-
- Run the CLI with the same config and note progression/claim time.
|
|
50
|
-
- Progression and claim times should be comparable (allow for Twitch-side variance).
|
|
51
|
-
|
|
52
|
-
## 5. Status command
|
|
53
|
-
|
|
54
|
-
- While the miner is running (or after it has run), in another terminal:
|
|
55
|
-
```bash
|
|
56
|
-
tdm status
|
|
57
|
-
tdm status --json
|
|
58
|
-
```
|
|
59
|
-
- Confirm `state` (e.g. WATCHING or MAINTENANCE) and `activeDrop` (e.g. “Game Name: Drop Name”) look correct.
|
|
60
|
-
|
|
61
|
-
## Success criteria
|
|
62
|
-
|
|
63
|
-
- With a test account and active drops campaign:
|
|
64
|
-
- `tdm run` increases “minutes watched” for the targeted drop on Twitch Inventory without opening a stream in the browser.
|
|
65
|
-
- Drops are claimed automatically when eligible (or a manual step is documented).
|
|
66
|
-
- `tdm run --dry-run --verbose` logs intended watch and claim actions without performing spade/claim network calls.
|
|
67
|
-
- `tdm status` shows a sensible state and active drop.
|
|
68
|
-
|
|
69
|
-
## Troubleshooting
|
|
70
|
-
|
|
71
|
-
- **No channels / “No channel candidates”**: Ensure the game has live streams with Drops enabled; try a different game or relax `priorityMode`.
|
|
72
|
-
- **Watch tick failed**: Spade URL extraction or auth may be failing; run with `--verbose` and check logs.
|
|
73
|
-
- **Minutes not updating**: Twitch can delay updates; wait 2–3 minutes and refresh the Inventory page. Ensure you’re watching a channel that has Drops for that campaign.
|
|
1
|
+
# Drops validation playbook
|
|
2
|
+
|
|
3
|
+
This playbook helps confirm that the CLI actually advances and claims Twitch Drops as intended, without opening a browser stream.
|
|
4
|
+
|
|
5
|
+
## Prerequisites
|
|
6
|
+
|
|
7
|
+
- A Twitch account (test account recommended).
|
|
8
|
+
- CLI installed and built: `npm run build`.
|
|
9
|
+
- Auth completed: `tdm auth login --no-open` (or paste token when prompted).
|
|
10
|
+
|
|
11
|
+
## 1. Configure for a single game
|
|
12
|
+
|
|
13
|
+
- Choose an active Drops campaign with a **short first drop** (e.g. 15–30 minutes) so you can see progress quickly.
|
|
14
|
+
- In config (e.g. `~/.config/tdm/config.json` or project `tdm.config.json`), set:
|
|
15
|
+
- `priority`: `["<Game Name>"]` (exact game name from the campaign).
|
|
16
|
+
- `priorityMode`: `"priority_only"` so only that game is mined.
|
|
17
|
+
- `exclude`: `[]` (or leave default).
|
|
18
|
+
|
|
19
|
+
## 2. Dry run (no network writes)
|
|
20
|
+
|
|
21
|
+
- Run with dry-run and verbose to see intended actions only:
|
|
22
|
+
```bash
|
|
23
|
+
tdm run --dry-run --verbose
|
|
24
|
+
```
|
|
25
|
+
- Confirm logs show:
|
|
26
|
+
- Inventory fetch and campaign list.
|
|
27
|
+
- Wanted games = your priority game.
|
|
28
|
+
- Channel fetch and selected channel.
|
|
29
|
+
- “Would send watch” (no real spade POST).
|
|
30
|
+
- “Would claim” for any claimable drop (no real ClaimDrop GQL).
|
|
31
|
+
- Stop with Ctrl+C.
|
|
32
|
+
|
|
33
|
+
## 3. Live run and Twitch Inventory check
|
|
34
|
+
|
|
35
|
+
- In a browser, open [Twitch Drops Inventory](https://www.twitch.tv/drops/inventory) and log in with the same account.
|
|
36
|
+
- Note the current “minutes watched” (and “Claim” button if the drop is ready) for the target campaign.
|
|
37
|
+
- Start the miner:
|
|
38
|
+
```bash
|
|
39
|
+
tdm run --verbose
|
|
40
|
+
```
|
|
41
|
+
- Let it run for at least one watch interval (about 1 minute). You should see “Watch tick sent for channel …” in the logs.
|
|
42
|
+
- Refresh the Twitch Inventory page: “minutes watched” for the active drop should increase (may take 1–2 minutes to reflect).
|
|
43
|
+
- If the drop becomes claimable, the CLI should auto-claim; check logs for “Claimed drop” and confirm the drop shows as claimed in the Inventory.
|
|
44
|
+
|
|
45
|
+
## 4. Compare with Python miner (optional)
|
|
46
|
+
|
|
47
|
+
- Using the same Twitch account and same priority game:
|
|
48
|
+
- Run the Python TwitchDropsMiner and note progression/claim time.
|
|
49
|
+
- Run the CLI with the same config and note progression/claim time.
|
|
50
|
+
- Progression and claim times should be comparable (allow for Twitch-side variance).
|
|
51
|
+
|
|
52
|
+
## 5. Status command
|
|
53
|
+
|
|
54
|
+
- While the miner is running (or after it has run), in another terminal:
|
|
55
|
+
```bash
|
|
56
|
+
tdm status
|
|
57
|
+
tdm status --json
|
|
58
|
+
```
|
|
59
|
+
- Confirm `state` (e.g. WATCHING or MAINTENANCE) and `activeDrop` (e.g. “Game Name: Drop Name”) look correct.
|
|
60
|
+
|
|
61
|
+
## Success criteria
|
|
62
|
+
|
|
63
|
+
- With a test account and active drops campaign:
|
|
64
|
+
- `tdm run` increases “minutes watched” for the targeted drop on Twitch Inventory without opening a stream in the browser.
|
|
65
|
+
- Drops are claimed automatically when eligible (or a manual step is documented).
|
|
66
|
+
- `tdm run --dry-run --verbose` logs intended watch and claim actions without performing spade/claim network calls.
|
|
67
|
+
- `tdm status` shows a sensible state and active drop.
|
|
68
|
+
|
|
69
|
+
## Troubleshooting
|
|
70
|
+
|
|
71
|
+
- **No channels / “No channel candidates”**: Ensure the game has live streams with Drops enabled; try a different game or relax `priorityMode`.
|
|
72
|
+
- **Watch tick failed**: Spade URL extraction or auth may be failing; run with `--verbose` and check logs.
|
|
73
|
+
- **Minutes not updating**: Twitch can delay updates; wait 2–3 minutes and refresh the Inventory page. Ensure you’re watching a channel that has Drops for that campaign.
|
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
## Linux install (headless)
|
|
2
|
-
|
|
3
|
-
- **Prereqs**: `node >= 20`, outbound HTTPS/WSS to Twitch.
|
|
4
|
-
- Install globally:
|
|
5
|
-
|
|
6
|
-
```bash
|
|
7
|
-
npm install -g twitchdropsminer-cli
|
|
8
|
-
```
|
|
9
|
-
|
|
10
|
-
- Verify environment:
|
|
11
|
-
|
|
12
|
-
```bash
|
|
13
|
-
tdm doctor
|
|
14
|
-
```
|
|
15
|
-
|
|
1
|
+
## Linux install (headless)
|
|
2
|
+
|
|
3
|
+
- **Prereqs**: `node >= 20`, outbound HTTPS/WSS to Twitch.
|
|
4
|
+
- Install globally:
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npm install -g twitchdropsminer-cli
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
- Verify environment:
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
tdm doctor
|
|
14
|
+
```
|
|
15
|
+
|
|
@@ -1,23 +1,23 @@
|
|
|
1
|
-
## Running as a service (systemd)
|
|
2
|
-
|
|
3
|
-
### Install user-level service
|
|
4
|
-
|
|
5
|
-
```bash
|
|
6
|
-
tdm service install --user --autostart
|
|
7
|
-
tdm service start
|
|
8
|
-
```
|
|
9
|
-
|
|
10
|
-
### Check status
|
|
11
|
-
|
|
12
|
-
```bash
|
|
13
|
-
tdm service status
|
|
14
|
-
```
|
|
15
|
-
|
|
16
|
-
### Logs (journalctl)
|
|
17
|
-
|
|
18
|
-
Use standard `journalctl` commands, e.g.:
|
|
19
|
-
|
|
20
|
-
```bash
|
|
21
|
-
journalctl --user -u tdm.service -f
|
|
22
|
-
```
|
|
23
|
-
|
|
1
|
+
## Running as a service (systemd)
|
|
2
|
+
|
|
3
|
+
### Install user-level service
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
tdm service install --user --autostart
|
|
7
|
+
tdm service start
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
### Check status
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
tdm service status
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
### Logs (journalctl)
|
|
17
|
+
|
|
18
|
+
Use standard `journalctl` commands, e.g.:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
journalctl --user -u tdm.service -f
|
|
22
|
+
```
|
|
23
|
+
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
## systemd hardening notes
|
|
2
|
-
|
|
3
|
-
Recommended service-level settings:
|
|
4
|
-
|
|
5
|
-
- `NoNewPrivileges=true`
|
|
6
|
-
- `PrivateTmp=true`
|
|
7
|
-
- `Restart=on-failure`
|
|
8
|
-
- `RestartSec=5`
|
|
9
|
-
- `After=network-online.target`
|
|
10
|
-
- `Wants=network-online.target`
|
|
11
|
-
|
|
12
|
-
Use user-level units for least privilege unless you explicitly need a system unit.
|
|
13
|
-
|
|
1
|
+
## systemd hardening notes
|
|
2
|
+
|
|
3
|
+
Recommended service-level settings:
|
|
4
|
+
|
|
5
|
+
- `NoNewPrivileges=true`
|
|
6
|
+
- `PrivateTmp=true`
|
|
7
|
+
- `Restart=on-failure`
|
|
8
|
+
- `RestartSec=5`
|
|
9
|
+
- `After=network-online.target`
|
|
10
|
+
- `Wants=network-online.target`
|
|
11
|
+
|
|
12
|
+
Use user-level units for least privilege unless you explicitly need a system unit.
|
|
13
|
+
|
package/package.json
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
[Unit]
|
|
2
|
-
Description=Twitch Drops Miner CLI
|
|
3
|
-
After=network-online.target
|
|
4
|
-
Wants=network-online.target
|
|
5
|
-
|
|
6
|
-
[Service]
|
|
7
|
-
Type=simple
|
|
8
|
-
ExecStart={{NODE_PATH}} {{TDM_BIN}} run
|
|
9
|
-
Restart=on-failure
|
|
10
|
-
RestartSec=5
|
|
11
|
-
NoNewPrivileges=true
|
|
12
|
-
PrivateTmp=true
|
|
13
|
-
Environment=TDM_LOG_LEVEL=info
|
|
14
|
-
|
|
15
|
-
[Install]
|
|
16
|
-
WantedBy=default.target
|
|
17
|
-
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=Twitch Drops Miner CLI
|
|
3
|
+
After=network-online.target
|
|
4
|
+
Wants=network-online.target
|
|
5
|
+
|
|
6
|
+
[Service]
|
|
7
|
+
Type=simple
|
|
8
|
+
ExecStart={{NODE_PATH}} {{TDM_BIN}} run
|
|
9
|
+
Restart=on-failure
|
|
10
|
+
RestartSec=5
|
|
11
|
+
NoNewPrivileges=true
|
|
12
|
+
PrivateTmp=true
|
|
13
|
+
Environment=TDM_LOG_LEVEL=info
|
|
14
|
+
|
|
15
|
+
[Install]
|
|
16
|
+
WantedBy=default.target
|
|
17
|
+
|