@nocville/npc 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +58 -120
- package/dist/cjs/cli.js +55 -34
- package/dist/cjs/core/base-npc.js +8 -0
- package/dist/cjs/core/config.js +19 -2
- package/dist/cjs/core/errors.js +23 -0
- package/dist/cjs/core/http.js +121 -0
- package/dist/cjs/core/pixel-art.js +110 -0
- package/dist/cjs/core/polling-npc.js +99 -14
- package/dist/cjs/core/session-filter.js +3 -1
- package/dist/cjs/core/text.js +13 -0
- package/dist/cjs/core/thresholds.js +29 -0
- package/dist/cjs/index.js +10 -2
- package/dist/cjs/npcs/agent-common/session.js +1 -0
- package/dist/cjs/npcs/github-watcher/defaults.js +7 -1
- package/dist/cjs/npcs/github-watcher/github-api.js +82 -45
- package/dist/cjs/npcs/github-watcher/index.js +82 -36
- package/dist/cjs/npcs/internet-connectivity/checks.js +70 -67
- package/dist/cjs/npcs/internet-connectivity/defaults.js +5 -2
- package/dist/cjs/npcs/internet-connectivity/index.js +134 -131
- package/dist/cjs/npcs/log-watcher/buffer.js +61 -0
- package/dist/cjs/npcs/log-watcher/index.js +109 -103
- package/dist/cjs/npcs/log-watcher/tail.js +100 -53
- package/dist/cjs/npcs/process-watcher/index.js +61 -51
- package/dist/cjs/npcs/process-watcher/process-scanner.js +63 -69
- package/dist/cjs/npcs/receptionist/defaults.js +4 -0
- package/dist/cjs/npcs/receptionist/index.js +45 -19
- package/dist/cjs/npcs/receptionist/weather.js +42 -47
- package/dist/cjs/npcs/system-monitor/collectors.js +66 -17
- package/dist/cjs/npcs/system-monitor/index.js +52 -28
- package/dist/cjs/npcs/web-page-watcher/basic-mode.js +30 -14
- package/dist/cjs/npcs/web-page-watcher/capture.js +23 -1
- package/dist/cjs/npcs/web-page-watcher/defaults.js +44 -2
- package/dist/cjs/npcs/web-page-watcher/index.js +119 -68
- package/dist/cjs/npcs/web-page-watcher/pixelate.js +2 -1
- package/dist/cli.js +56 -35
- package/dist/core/base-npc.d.ts +5 -0
- package/dist/core/base-npc.js +8 -0
- package/dist/core/config.d.ts +5 -0
- package/dist/core/config.js +18 -2
- package/dist/core/errors.d.ts +11 -0
- package/dist/core/errors.js +18 -0
- package/dist/core/http.d.ts +52 -0
- package/dist/core/http.js +111 -0
- package/dist/core/pixel-art.d.ts +38 -0
- package/dist/core/pixel-art.js +105 -0
- package/dist/core/polling-npc.d.ts +22 -1
- package/dist/core/polling-npc.js +98 -13
- package/dist/core/session-filter.d.ts +2 -2
- package/dist/core/session-filter.js +3 -1
- package/dist/core/text.d.ts +6 -0
- package/dist/core/text.js +12 -0
- package/dist/core/thresholds.d.ts +9 -1
- package/dist/core/thresholds.js +27 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.js +4 -2
- package/dist/npcs/agent-common/session.js +1 -0
- package/dist/npcs/github-watcher/defaults.d.ts +6 -0
- package/dist/npcs/github-watcher/defaults.js +7 -1
- package/dist/npcs/github-watcher/github-api.d.ts +21 -7
- package/dist/npcs/github-watcher/github-api.js +80 -45
- package/dist/npcs/github-watcher/index.d.ts +6 -0
- package/dist/npcs/github-watcher/index.js +83 -37
- package/dist/npcs/internet-connectivity/checks.d.ts +15 -18
- package/dist/npcs/internet-connectivity/checks.js +70 -67
- package/dist/npcs/internet-connectivity/defaults.d.ts +3 -0
- package/dist/npcs/internet-connectivity/defaults.js +5 -2
- package/dist/npcs/internet-connectivity/index.d.ts +19 -6
- package/dist/npcs/internet-connectivity/index.js +135 -133
- package/dist/npcs/log-watcher/buffer.d.ts +22 -0
- package/dist/npcs/log-watcher/buffer.js +58 -0
- package/dist/npcs/log-watcher/index.d.ts +22 -21
- package/dist/npcs/log-watcher/index.js +112 -106
- package/dist/npcs/log-watcher/tail.d.ts +23 -3
- package/dist/npcs/log-watcher/tail.js +101 -54
- package/dist/npcs/process-watcher/index.d.ts +1 -1
- package/dist/npcs/process-watcher/index.js +62 -52
- package/dist/npcs/process-watcher/process-scanner.d.ts +29 -1
- package/dist/npcs/process-watcher/process-scanner.js +61 -71
- package/dist/npcs/receptionist/defaults.d.ts +4 -0
- package/dist/npcs/receptionist/defaults.js +4 -0
- package/dist/npcs/receptionist/index.d.ts +12 -2
- package/dist/npcs/receptionist/index.js +46 -20
- package/dist/npcs/receptionist/weather.d.ts +8 -6
- package/dist/npcs/receptionist/weather.js +39 -45
- package/dist/npcs/system-monitor/collectors.d.ts +15 -3
- package/dist/npcs/system-monitor/collectors.js +64 -17
- package/dist/npcs/system-monitor/defaults.d.ts +1 -1
- package/dist/npcs/system-monitor/index.d.ts +2 -1
- package/dist/npcs/system-monitor/index.js +54 -30
- package/dist/npcs/web-page-watcher/basic-mode.d.ts +2 -2
- package/dist/npcs/web-page-watcher/basic-mode.js +30 -14
- package/dist/npcs/web-page-watcher/capture.d.ts +18 -0
- package/dist/npcs/web-page-watcher/capture.js +21 -1
- package/dist/npcs/web-page-watcher/defaults.d.ts +23 -0
- package/dist/npcs/web-page-watcher/defaults.js +41 -1
- package/dist/npcs/web-page-watcher/index.d.ts +18 -1
- package/dist/npcs/web-page-watcher/index.js +121 -70
- package/dist/npcs/web-page-watcher/pixelate.js +2 -1
- package/package.json +2 -7
|
@@ -1,102 +1,139 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isValidRepo = isValidRepo;
|
|
3
4
|
exports.fetchDefaultBranch = fetchDefaultBranch;
|
|
4
5
|
exports.fetchCommits = fetchCommits;
|
|
6
|
+
exports.parseCommit = parseCommit;
|
|
5
7
|
exports.commitsWithin = commitsWithin;
|
|
6
8
|
exports.newCommitsSince = newCommitsSince;
|
|
9
|
+
const http_js_1 = require("../../core/http.js");
|
|
10
|
+
const text_js_1 = require("../../core/text.js");
|
|
11
|
+
/** Largest GitHub API response we read (10 commits are ~30-60 KB). */
|
|
12
|
+
const MAX_BODY_BYTES = 512 * 1024;
|
|
13
|
+
// GitHub owner and repository names: letters, digits, '-', '_' and '.'.
|
|
14
|
+
const REPO_RE = /^([A-Za-z0-9-]{1,39})\/([A-Za-z0-9._-]{1,100})$/;
|
|
15
|
+
/**
|
|
16
|
+
* Validate an "owner/name" repository slug. It is interpolated into the API
|
|
17
|
+
* URL path, so anything else (`../`, extra segments, query characters) is
|
|
18
|
+
* rejected rather than letting it — and the token — reach another endpoint.
|
|
19
|
+
*/
|
|
20
|
+
function isValidRepo(repo) {
|
|
21
|
+
const m = repo.match(REPO_RE);
|
|
22
|
+
return !!m && m[2] !== '.' && m[2] !== '..';
|
|
23
|
+
}
|
|
7
24
|
function toNum(v) {
|
|
8
|
-
if (v === null)
|
|
25
|
+
if (v === null || v.trim() === '')
|
|
9
26
|
return null;
|
|
10
27
|
const n = Number(v);
|
|
11
|
-
return Number.
|
|
28
|
+
return Number.isFinite(n) ? n : null;
|
|
12
29
|
}
|
|
13
30
|
function ghHeaders(opts) {
|
|
14
31
|
const headers = {
|
|
15
32
|
Accept: 'application/vnd.github+json',
|
|
16
|
-
'User-Agent':
|
|
33
|
+
'User-Agent': http_js_1.NPC_USER_AGENT,
|
|
17
34
|
'X-GitHub-Api-Version': '2022-11-28',
|
|
18
35
|
};
|
|
19
36
|
if (opts.token)
|
|
20
37
|
headers['Authorization'] = `Bearer ${opts.token}`;
|
|
21
38
|
return headers;
|
|
22
39
|
}
|
|
40
|
+
function doFetchFn(opts) {
|
|
41
|
+
return opts.fetchFn ?? globalThis.fetch;
|
|
42
|
+
}
|
|
43
|
+
async function readJson(res) {
|
|
44
|
+
const { text, truncated } = await (0, http_js_1.readTextCapped)(res, MAX_BODY_BYTES);
|
|
45
|
+
if (truncated)
|
|
46
|
+
throw new Error('response too large');
|
|
47
|
+
return JSON.parse(text);
|
|
48
|
+
}
|
|
23
49
|
/** Look up a repo's default branch (one call at startup). Null on failure. */
|
|
24
50
|
async function fetchDefaultBranch(repo, opts = {}) {
|
|
25
|
-
const doFetch = opts.fetchFn ?? globalThis.fetch;
|
|
26
|
-
const controller = new AbortController();
|
|
27
|
-
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 5000);
|
|
28
51
|
try {
|
|
29
|
-
const res = await
|
|
52
|
+
const res = await doFetchFn(opts)(`https://api.github.com/repos/${repo}`, {
|
|
30
53
|
headers: ghHeaders(opts),
|
|
31
|
-
signal:
|
|
54
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? 5000),
|
|
32
55
|
});
|
|
33
56
|
if (!res.ok)
|
|
34
57
|
return null;
|
|
35
|
-
const
|
|
36
|
-
return
|
|
58
|
+
const branch = (0, http_js_1.asRecord)(await readJson(res))?.['default_branch'];
|
|
59
|
+
return typeof branch === 'string' && branch.length > 0 && branch.length <= 255
|
|
60
|
+
? (0, text_js_1.sanitizeText)(branch, 255)
|
|
61
|
+
: null;
|
|
37
62
|
}
|
|
38
63
|
catch {
|
|
39
64
|
return null;
|
|
40
65
|
}
|
|
41
|
-
finally {
|
|
42
|
-
clearTimeout(timer);
|
|
43
|
-
}
|
|
44
66
|
}
|
|
45
67
|
/**
|
|
46
68
|
* Fetch recent commits from the GitHub REST API using conditional requests.
|
|
47
|
-
* Passing the previous ETag yields a 304
|
|
48
|
-
*
|
|
49
|
-
*
|
|
69
|
+
* Passing the previous ETag yields a cheap 304 when nothing has changed (free
|
|
70
|
+
* of the rate limit for authenticated requests). Never throws — network
|
|
71
|
+
* errors and unreadable bodies surface as `status: 0`.
|
|
50
72
|
*/
|
|
51
73
|
async function fetchCommits(repo, opts = {}) {
|
|
52
74
|
const perPage = opts.perPage ?? 10;
|
|
53
|
-
const doFetch = opts.fetchFn ?? globalThis.fetch;
|
|
54
75
|
let url = `https://api.github.com/repos/${repo}/commits?per_page=${perPage}`;
|
|
55
76
|
if (opts.branch)
|
|
56
77
|
url += `&sha=${encodeURIComponent(opts.branch)}`;
|
|
57
78
|
const headers = ghHeaders(opts);
|
|
58
79
|
if (opts.etag)
|
|
59
80
|
headers['If-None-Match'] = opts.etag;
|
|
60
|
-
const
|
|
61
|
-
|
|
81
|
+
const failed = {
|
|
82
|
+
notModified: false,
|
|
83
|
+
commits: [],
|
|
84
|
+
etag: null,
|
|
85
|
+
rateRemaining: null,
|
|
86
|
+
rateResetAt: null,
|
|
87
|
+
status: 0,
|
|
88
|
+
};
|
|
62
89
|
try {
|
|
63
|
-
const res = await
|
|
90
|
+
const res = await doFetchFn(opts)(url, {
|
|
91
|
+
headers,
|
|
92
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? 5000),
|
|
93
|
+
});
|
|
64
94
|
const rateRemaining = toNum(res.headers.get('x-ratelimit-remaining'));
|
|
95
|
+
const resetSecs = toNum(res.headers.get('x-ratelimit-reset'));
|
|
96
|
+
const rateResetAt = resetSecs === null ? null : resetSecs * 1000;
|
|
65
97
|
const etag = res.headers.get('etag');
|
|
98
|
+
const meta = { rateRemaining, rateResetAt, status: res.status };
|
|
66
99
|
if (res.status === 304) {
|
|
67
|
-
return {
|
|
68
|
-
notModified: true,
|
|
69
|
-
commits: [],
|
|
70
|
-
etag: opts.etag ?? null,
|
|
71
|
-
rateRemaining,
|
|
72
|
-
status: 304,
|
|
73
|
-
};
|
|
100
|
+
return { ...meta, notModified: true, commits: [], etag: opts.etag ?? null };
|
|
74
101
|
}
|
|
75
102
|
if (!res.ok) {
|
|
76
|
-
await res.
|
|
77
|
-
return { notModified: false, commits: [], etag
|
|
103
|
+
await res.body?.cancel().catch(() => undefined);
|
|
104
|
+
return { ...meta, notModified: false, commits: [], etag };
|
|
78
105
|
}
|
|
79
|
-
const body =
|
|
80
|
-
|
|
81
|
-
|
|
106
|
+
const body = await readJson(res);
|
|
107
|
+
if (!Array.isArray(body))
|
|
108
|
+
return failed;
|
|
109
|
+
const commits = body.map(parseCommit).filter((c) => c !== null);
|
|
110
|
+
return { ...meta, notModified: false, commits, etag };
|
|
82
111
|
}
|
|
83
112
|
catch {
|
|
84
|
-
return
|
|
85
|
-
}
|
|
86
|
-
finally {
|
|
87
|
-
clearTimeout(timer);
|
|
113
|
+
return failed;
|
|
88
114
|
}
|
|
89
115
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
116
|
+
/**
|
|
117
|
+
* Extract the displayed fields from one (untrusted) commit object: a hex sha,
|
|
118
|
+
* the subject line and author name with control/bidi characters removed and
|
|
119
|
+
* lengths capped. Null for anything without a valid sha.
|
|
120
|
+
*/
|
|
121
|
+
function parseCommit(raw) {
|
|
122
|
+
const c = (0, http_js_1.asRecord)(raw);
|
|
123
|
+
const sha = c?.['sha'];
|
|
124
|
+
if (typeof sha !== 'string' || !/^[0-9a-f]{7,64}$/i.test(sha))
|
|
125
|
+
return null;
|
|
126
|
+
const commit = (0, http_js_1.asRecord)(c['commit']) ?? {};
|
|
127
|
+
const commitAuthor = (0, http_js_1.asRecord)(commit['author']) ?? {};
|
|
128
|
+
const ghAuthor = (0, http_js_1.asRecord)(c['author']) ?? {};
|
|
129
|
+
const str = (v) => (typeof v === 'string' && v.trim() ? v : null);
|
|
130
|
+
const message = str(commit['message'])?.split('\n')[0] ?? '';
|
|
131
|
+
const date = str(commitAuthor['date']) ?? '';
|
|
95
132
|
return {
|
|
96
|
-
sha:
|
|
97
|
-
author:
|
|
98
|
-
message,
|
|
99
|
-
date:
|
|
133
|
+
sha: sha.toLowerCase(),
|
|
134
|
+
author: (0, text_js_1.sanitizeText)(str(commitAuthor['name']) ?? str(ghAuthor['login']) ?? 'unknown', 40),
|
|
135
|
+
message: (0, text_js_1.sanitizeText)(message, 200),
|
|
136
|
+
date: Number.isFinite(Date.parse(date)) ? date : '',
|
|
100
137
|
};
|
|
101
138
|
}
|
|
102
139
|
/** Count how many commits landed within the last `hours`. */
|
|
@@ -3,6 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.GitHubWatcherNPC = void 0;
|
|
4
4
|
const polling_npc_js_1 = require("../../core/polling-npc.js");
|
|
5
5
|
const text_js_1 = require("../../core/text.js");
|
|
6
|
+
const thresholds_js_1 = require("../../core/thresholds.js");
|
|
7
|
+
const errors_js_1 = require("../../core/errors.js");
|
|
6
8
|
const defaults_js_1 = require("./defaults.js");
|
|
7
9
|
const github_api_js_1 = require("./github-api.js");
|
|
8
10
|
const format_js_1 = require("./format.js");
|
|
@@ -15,12 +17,24 @@ class GitHubWatcherNPC extends polling_npc_js_1.PollingNPC {
|
|
|
15
17
|
lastSeenSha = null;
|
|
16
18
|
commits = [];
|
|
17
19
|
rateRemaining = null;
|
|
20
|
+
/** While rate-limited, don't call GitHub again before this time (epoch ms). */
|
|
21
|
+
rateLimitedUntil = 0;
|
|
22
|
+
consecutiveFailures = 0;
|
|
18
23
|
newCommitPollsLeft = 0;
|
|
19
24
|
constructor(config) {
|
|
20
|
-
super({
|
|
25
|
+
super({
|
|
26
|
+
...config,
|
|
27
|
+
// Unauthenticated requests get 60/hour and 304s still count against
|
|
28
|
+
// them, so a 60s poll (plus the branch lookup) would hit the limit.
|
|
29
|
+
pollInterval: config.pollInterval ??
|
|
30
|
+
(config.token ? defaults_js_1.DEFAULTS.pollInterval : defaults_js_1.DEFAULTS.unauthenticatedPollInterval),
|
|
31
|
+
});
|
|
32
|
+
if (!(0, github_api_js_1.isValidRepo)(config.repo)) {
|
|
33
|
+
throw new errors_js_1.NpcConfigError(`--repo must look like owner/name (got "${(0, text_js_1.truncate)(config.repo, 80)}")`);
|
|
34
|
+
}
|
|
21
35
|
this.repo = config.repo;
|
|
22
|
-
this.token = config.token;
|
|
23
|
-
this.branch = config.branch;
|
|
36
|
+
this.token = config.token || undefined;
|
|
37
|
+
this.branch = config.branch?.trim() || undefined;
|
|
24
38
|
this.fetchFn = config.fetchFn;
|
|
25
39
|
}
|
|
26
40
|
get name() {
|
|
@@ -42,7 +56,8 @@ class GitHubWatcherNPC extends polling_npc_js_1.PollingNPC {
|
|
|
42
56
|
};
|
|
43
57
|
}
|
|
44
58
|
async onStart() {
|
|
45
|
-
// Resolve the default branch once if the user didn't pin one
|
|
59
|
+
// Resolve the default branch once if the user didn't pin one (it only
|
|
60
|
+
// labels the board; the commits API already defaults to that branch).
|
|
46
61
|
if (!this.branch) {
|
|
47
62
|
const def = await (0, github_api_js_1.fetchDefaultBranch)(this.repo, {
|
|
48
63
|
token: this.token,
|
|
@@ -57,6 +72,8 @@ class GitHubWatcherNPC extends polling_npc_js_1.PollingNPC {
|
|
|
57
72
|
await super.onStart();
|
|
58
73
|
}
|
|
59
74
|
async collect() {
|
|
75
|
+
if (Date.now() < this.rateLimitedUntil)
|
|
76
|
+
return this.rateLimited();
|
|
60
77
|
const result = await (0, github_api_js_1.fetchCommits)(this.repo, {
|
|
61
78
|
token: this.token,
|
|
62
79
|
etag: this.etag,
|
|
@@ -67,39 +84,68 @@ class GitHubWatcherNPC extends polling_npc_js_1.PollingNPC {
|
|
|
67
84
|
});
|
|
68
85
|
if (result.rateRemaining !== null)
|
|
69
86
|
this.rateRemaining = result.rateRemaining;
|
|
70
|
-
|
|
71
|
-
|
|
87
|
+
const failure = this.classifyFailure(result);
|
|
88
|
+
if (failure)
|
|
89
|
+
return failure;
|
|
90
|
+
this.consecutiveFailures = 0;
|
|
91
|
+
if (result.notModified)
|
|
92
|
+
return this.reportIdle();
|
|
93
|
+
if (result.status === 409)
|
|
94
|
+
return this.report('healthy', 'Repository is empty');
|
|
95
|
+
this.etag = result.etag ?? this.etag;
|
|
96
|
+
if (result.commits.length === 0)
|
|
97
|
+
return this.reportIdle();
|
|
98
|
+
const fresh = (0, github_api_js_1.newCommitsSince)(result.commits, this.lastSeenSha);
|
|
99
|
+
this.commits = result.commits;
|
|
100
|
+
const seeded = this.lastSeenSha !== null;
|
|
101
|
+
this.lastSeenSha = result.commits[0].sha;
|
|
102
|
+
if (!seeded || fresh.length === 0)
|
|
103
|
+
return this.reportIdle();
|
|
104
|
+
this.newCommitPollsLeft = defaults_js_1.DEFAULTS.newCommitPolls;
|
|
105
|
+
return this.reportNewCommits(fresh);
|
|
106
|
+
}
|
|
107
|
+
/** Map an error response to a report, or null when the fetch succeeded. */
|
|
108
|
+
classifyFailure(result) {
|
|
109
|
+
const { status } = result;
|
|
110
|
+
if (status === 304 || status === 409 || (status >= 200 && status < 300))
|
|
111
|
+
return null;
|
|
112
|
+
if (status === 429 || (status === 403 && result.rateRemaining === 0)) {
|
|
113
|
+
this.rateLimitedUntil = result.rateResetAt ?? Date.now() + 15 * 60_000;
|
|
114
|
+
if (!this.token) {
|
|
115
|
+
this.logger.warn('GitHub rate limit reached; set GITHUB_TOKEN for a higher limit');
|
|
116
|
+
}
|
|
117
|
+
return this.rateLimited();
|
|
72
118
|
}
|
|
73
|
-
if (
|
|
74
|
-
return this.report('warning', '
|
|
119
|
+
if (status === 401) {
|
|
120
|
+
return this.report('warning', 'GitHub token rejected (401)', 'auth-failed');
|
|
75
121
|
}
|
|
76
|
-
if (
|
|
77
|
-
return this.report('warning',
|
|
122
|
+
if (status === 403) {
|
|
123
|
+
return this.report('warning', `Access to ${this.repo} denied (403)`, 'forbidden');
|
|
78
124
|
}
|
|
79
|
-
if (
|
|
80
|
-
|
|
125
|
+
if (status === 404) {
|
|
126
|
+
const hint = this.token ? '' : ' (private repos need GITHUB_TOKEN)';
|
|
127
|
+
return this.report('warning', `Repo not found: ${this.repo}${hint}`, 'not-found');
|
|
81
128
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (this.lastSeenSha === null) {
|
|
87
|
-
this.lastSeenSha = result.commits[0].sha;
|
|
88
|
-
return this.reportIdle();
|
|
89
|
-
}
|
|
90
|
-
if (fresh.length > 0) {
|
|
91
|
-
this.lastSeenSha = result.commits[0].sha;
|
|
92
|
-
this.newCommitPollsLeft = defaults_js_1.DEFAULTS.newCommitPolls;
|
|
93
|
-
return this.reportNewCommits(fresh);
|
|
94
|
-
}
|
|
129
|
+
// Network error, timeout or 5xx: ride out blips while there's a board to show.
|
|
130
|
+
this.consecutiveFailures++;
|
|
131
|
+
if (this.commits.length > 0 && this.consecutiveFailures < defaults_js_1.DEFAULTS.failuresBeforeWarning) {
|
|
132
|
+
return this.reportIdle();
|
|
95
133
|
}
|
|
96
|
-
|
|
134
|
+
const what = status === 0 ? 'unreachable' : `error (HTTP ${status})`;
|
|
135
|
+
return this.report('warning', `GitHub API ${what}`, 'unreachable');
|
|
136
|
+
}
|
|
137
|
+
rateLimited() {
|
|
138
|
+
const resumes = new Date(this.rateLimitedUntil).toLocaleTimeString([], {
|
|
139
|
+
hour: '2-digit',
|
|
140
|
+
minute: '2-digit',
|
|
141
|
+
});
|
|
142
|
+
return this.report('warning', `GitHub API rate-limited until ${resumes}`, 'rate-limited');
|
|
97
143
|
}
|
|
98
144
|
reportNewCommits(fresh) {
|
|
99
145
|
const newest = fresh[0];
|
|
100
146
|
const latest = `${newest.author}: ${(0, text_js_1.truncate)(newest.message, 120)} (${newest.sha.slice(0, 7)})`;
|
|
101
147
|
const message = fresh.length > 1 ? `${fresh.length} new commits — latest: ${latest}` : latest;
|
|
102
|
-
return this.report('healthy',
|
|
148
|
+
return this.report('healthy', message, 'new-commit');
|
|
103
149
|
}
|
|
104
150
|
reportIdle() {
|
|
105
151
|
if (this.newCommitPollsLeft > 0)
|
|
@@ -109,16 +155,16 @@ class GitHubWatcherNPC extends polling_npc_js_1.PollingNPC {
|
|
|
109
155
|
const message = newest
|
|
110
156
|
? `Last: ${(0, text_js_1.truncate)(newest.message, 80)} (${(0, format_js_1.relativeTime)(newest.date)})`
|
|
111
157
|
: `Watching ${this.repo}`;
|
|
112
|
-
return this.report('healthy',
|
|
158
|
+
return this.report('healthy', message, subStatus);
|
|
113
159
|
}
|
|
114
|
-
report(status,
|
|
115
|
-
const
|
|
116
|
-
const secondsSinceLast =
|
|
117
|
-
? Math.max(0, Math.round((Date.now() -
|
|
160
|
+
report(status, message, subStatus) {
|
|
161
|
+
const newestAt = Date.parse(this.commits[0]?.date ?? '');
|
|
162
|
+
const secondsSinceLast = Number.isFinite(newestAt)
|
|
163
|
+
? Math.max(0, Math.round((Date.now() - newestAt) / 1000))
|
|
118
164
|
: 0;
|
|
119
165
|
return {
|
|
120
166
|
status,
|
|
121
|
-
mood,
|
|
167
|
+
mood: (0, thresholds_js_1.moodForStatus)(status),
|
|
122
168
|
message,
|
|
123
169
|
subStatus,
|
|
124
170
|
screenData: this.buildBoard(),
|
|
@@ -132,9 +178,9 @@ class GitHubWatcherNPC extends polling_npc_js_1.PollingNPC {
|
|
|
132
178
|
buildBoard() {
|
|
133
179
|
const now = Date.now();
|
|
134
180
|
const header = this.branch ? `${this.repo}@${this.branch}` : this.repo;
|
|
135
|
-
const values = [{ label: 'repo', value: header }];
|
|
136
|
-
for (const c of this.commits.slice(0,
|
|
137
|
-
const recent =
|
|
181
|
+
const values = [{ label: 'repo', value: (0, text_js_1.truncate)(header, 60) }];
|
|
182
|
+
for (const c of this.commits.slice(0, defaults_js_1.DEFAULTS.boardRows)) {
|
|
183
|
+
const recent = now - Date.parse(c.date) < 3600_000;
|
|
138
184
|
values.push({
|
|
139
185
|
label: c.sha.slice(0, 7),
|
|
140
186
|
value: (0, text_js_1.truncate)(`${c.author}: ${c.message}`, 60),
|
|
@@ -8,85 +8,88 @@ exports.checkHttp = checkHttp;
|
|
|
8
8
|
exports.checkLatency = checkLatency;
|
|
9
9
|
const node_dns_1 = require("node:dns");
|
|
10
10
|
const node_net_1 = __importDefault(require("node:net"));
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
const http_js_1 = require("../../core/http.js");
|
|
12
|
+
function errorText(err) {
|
|
13
|
+
return err instanceof Error ? err.message : String(err);
|
|
13
14
|
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
async function checkHttp(urls, timeout) {
|
|
30
|
-
return Promise.all(urls.map((url) => fetchSingleUrl(url, timeout)));
|
|
31
|
-
}
|
|
32
|
-
async function fetchSingleUrl(url, timeout) {
|
|
33
|
-
const controller = new AbortController();
|
|
34
|
-
const timer = setTimeout(() => controller.abort(), timeout);
|
|
35
|
-
const start = performance.now();
|
|
15
|
+
// ---------- DNS ----------
|
|
16
|
+
/**
|
|
17
|
+
* Resolve each host's A records through a dedicated resolver with a bounded
|
|
18
|
+
* timeout (the default resolver retries for tens of seconds when DNS is down).
|
|
19
|
+
*/
|
|
20
|
+
async function checkDns(hosts, timeoutMs) {
|
|
21
|
+
const resolver = new node_dns_1.promises.Resolver({
|
|
22
|
+
timeout: Math.max(250, Math.floor(timeoutMs / 2)),
|
|
23
|
+
tries: 2,
|
|
24
|
+
});
|
|
25
|
+
let timer;
|
|
26
|
+
const deadline = new Promise((resolve) => {
|
|
27
|
+
timer = setTimeout(() => resolve('timeout'), timeoutMs);
|
|
28
|
+
});
|
|
36
29
|
try {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
url,
|
|
50
|
-
ok: false,
|
|
51
|
-
timeMs: performance.now() - start,
|
|
52
|
-
error: err instanceof Error ? err.message : String(err),
|
|
53
|
-
};
|
|
30
|
+
return await Promise.all(hosts.map(async (host) => {
|
|
31
|
+
const start = performance.now();
|
|
32
|
+
try {
|
|
33
|
+
const outcome = await Promise.race([resolver.resolve4(host), deadline]);
|
|
34
|
+
if (outcome === 'timeout')
|
|
35
|
+
return { target: host, ok: false, timeMs: 0, error: 'timeout' };
|
|
36
|
+
return { target: host, ok: true, timeMs: performance.now() - start };
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
return { target: host, ok: false, timeMs: 0, error: errorText(err) };
|
|
40
|
+
}
|
|
41
|
+
}));
|
|
54
42
|
}
|
|
55
43
|
finally {
|
|
56
44
|
clearTimeout(timer);
|
|
45
|
+
resolver.cancel();
|
|
57
46
|
}
|
|
58
47
|
}
|
|
59
|
-
|
|
60
|
-
|
|
48
|
+
// ---------- HTTP ----------
|
|
49
|
+
/**
|
|
50
|
+
* Time to response headers for each URL. The body is discarded unread, so a
|
|
51
|
+
* large or endless response costs nothing. Only 2xx counts as reachable.
|
|
52
|
+
*/
|
|
53
|
+
async function checkHttp(urls, timeoutMs) {
|
|
54
|
+
return Promise.all(urls.map(async (url) => {
|
|
55
|
+
const target = (0, http_js_1.displayUrl)(url); // never echo credentials or query tokens
|
|
56
|
+
const start = performance.now();
|
|
57
|
+
try {
|
|
58
|
+
const res = await fetch(url, {
|
|
59
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
60
|
+
headers: { 'User-Agent': http_js_1.NPC_USER_AGENT },
|
|
61
|
+
});
|
|
62
|
+
const timeMs = performance.now() - start;
|
|
63
|
+
await (0, http_js_1.discardBody)(res);
|
|
64
|
+
return res.ok
|
|
65
|
+
? { target, ok: true, timeMs }
|
|
66
|
+
: { target, ok: false, timeMs: 0, error: `HTTP ${res.status}` };
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
return { target, ok: false, timeMs: 0, error: errorText(err) };
|
|
70
|
+
}
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
73
|
+
// ---------- Latency (TCP connect) ----------
|
|
74
|
+
/** Time to establish a TCP connection to each host:port. */
|
|
75
|
+
async function checkLatency(hosts, timeoutMs) {
|
|
76
|
+
return Promise.all(hosts.map((h) => measureTcpConnect(h, timeoutMs)));
|
|
61
77
|
}
|
|
62
|
-
function measureTcpConnect(target) {
|
|
78
|
+
function measureTcpConnect(target, timeoutMs) {
|
|
79
|
+
const label = target.host.includes(':')
|
|
80
|
+
? `[${target.host}]:${target.port}`
|
|
81
|
+
: `${target.host}:${target.port}`;
|
|
63
82
|
return new Promise((resolve) => {
|
|
64
83
|
const start = performance.now();
|
|
65
84
|
const socket = node_net_1.default.connect({ host: target.host, port: target.port });
|
|
66
|
-
const
|
|
85
|
+
const finish = (result) => {
|
|
86
|
+
clearTimeout(timer);
|
|
67
87
|
socket.destroy();
|
|
68
|
-
resolve(
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
});
|
|
74
|
-
}, 5000);
|
|
75
|
-
socket.on('connect', () => {
|
|
76
|
-
clearTimeout(timeout);
|
|
77
|
-
const timeMs = performance.now() - start;
|
|
78
|
-
socket.destroy();
|
|
79
|
-
resolve({ host: target.host, ok: true, timeMs });
|
|
80
|
-
});
|
|
81
|
-
socket.on('error', (err) => {
|
|
82
|
-
clearTimeout(timeout);
|
|
83
|
-
resolve({
|
|
84
|
-
host: target.host,
|
|
85
|
-
ok: false,
|
|
86
|
-
timeMs: performance.now() - start,
|
|
87
|
-
error: err.message,
|
|
88
|
-
});
|
|
89
|
-
});
|
|
88
|
+
resolve(result);
|
|
89
|
+
};
|
|
90
|
+
const timer = setTimeout(() => finish({ target: label, ok: false, timeMs: 0, error: 'Connection timed out' }), timeoutMs);
|
|
91
|
+
socket.once('connect', () => finish({ target: label, ok: true, timeMs: performance.now() - start }));
|
|
92
|
+
socket.once('error', (err) => finish({ target: label, ok: false, timeMs: 0, error: err.message }));
|
|
90
93
|
});
|
|
91
94
|
}
|
|
92
95
|
//# sourceMappingURL=checks.js.map
|
|
@@ -7,10 +7,13 @@ exports.DEFAULTS = {
|
|
|
7
7
|
// gstatic generate_204 is a stable, low-latency 204 endpoint used by many
|
|
8
8
|
// OSes for connectivity checks (replaces the flaky httpbin.org default).
|
|
9
9
|
httpUrls: ['https://1.1.1.1/cdn-cgi/trace', 'https://www.gstatic.com/generate_204'],
|
|
10
|
+
// 443, not 80: Google's 8.8.8.8 doesn't listen on port 80, so that default
|
|
11
|
+
// timed out on every poll and pinned the NPC at warning.
|
|
10
12
|
latencyHosts: [
|
|
11
|
-
{ host: '8.8.8.8', port:
|
|
12
|
-
{ host: '1.1.1.1', port:
|
|
13
|
+
{ host: '8.8.8.8', port: 443 },
|
|
14
|
+
{ host: '1.1.1.1', port: 443 },
|
|
13
15
|
],
|
|
16
|
+
latencyPort: 80,
|
|
14
17
|
slowThreshold: 1000, // ms
|
|
15
18
|
httpTimeout: 5000, // ms
|
|
16
19
|
thresholds: {
|