meta-sovereign 0.49.2 → 0.49.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/js/src/server/aggregate.js +16 -2
- package/js/src/server/index.js +13 -9
- package/js/src/server/routes-pull.js +147 -0
- package/js/src/sources/auto-import/files.js +5 -1
- package/js/src/sources/auto-import/recipes.js +18 -1
- package/js/src/sources/pull-runner.js +171 -0
- package/js/src/sources/vk-html-archive.js +268 -0
- package/js/src/sources/vk-limits.js +119 -0
- package/js/src/sources/vk-longpoll.js +333 -0
- package/js/src/sources/vk-sync.js +138 -0
- package/js/src/sources/vk.js +224 -71
- package/js/src/storage/migrations/0003-vk-reactions-docs.js +95 -0
- package/js/src/storage/migrations/index.js +3 -2
- package/js/src/web/app.css +43 -0
- package/js/src/web/chat-media.js +3 -0
- package/js/src/web/chat-remote-media.js +111 -0
- package/js/src/web/connections/fetch-now.js +79 -0
- package/js/src/web/connections/index.js +4 -0
- package/js/src/web/dom.js +7 -0
- package/js/src/web/locales/en-chat-account.js +17 -0
- package/js/src/web/locales/hi-chat-account.js +17 -0
- package/js/src/web/locales/ru-chat-account.js +17 -0
- package/js/src/web/locales/zh-chat-account.js +16 -0
- package/js/src/web/message-parts.js +34 -9
- package/package.json +3 -1
|
@@ -27,13 +27,27 @@ const blank = (id) => ({
|
|
|
27
27
|
open: null,
|
|
28
28
|
});
|
|
29
29
|
|
|
30
|
+
// A message time as an ISO string. VK and WhatsApp keep unix seconds (a
|
|
31
|
+
// number), which used to break the contact sort and `/api/status` (R-Z8).
|
|
32
|
+
const seenAt = (value) => {
|
|
33
|
+
if (value === null || value === undefined || value === '') {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const n = Number(value);
|
|
37
|
+
if (Number.isFinite(n) && !/[-:T]/.test(String(value))) {
|
|
38
|
+
return new Date(n < 1e12 ? n * 1000 : n).toISOString();
|
|
39
|
+
}
|
|
40
|
+
return String(value);
|
|
41
|
+
};
|
|
42
|
+
|
|
30
43
|
const addMessage = (entry, m) => {
|
|
31
44
|
entry.networks.add(m.source);
|
|
32
45
|
entry.chats.add(m.chat);
|
|
33
46
|
entry.messageCount += 1;
|
|
34
|
-
const
|
|
47
|
+
const at = seenAt(m.timestamp);
|
|
48
|
+
const latest = !entry.lastSeen || (at ?? '') > entry.lastSeen;
|
|
35
49
|
if (latest) {
|
|
36
|
-
entry.lastSeen =
|
|
50
|
+
entry.lastSeen = at;
|
|
37
51
|
entry.name = m.senderName ?? entry.name;
|
|
38
52
|
} else {
|
|
39
53
|
entry.name ??= m.senderName ?? null;
|
package/js/src/server/index.js
CHANGED
|
@@ -104,6 +104,7 @@ import { createGitHubCheck } from '../sources/github-check.js';
|
|
|
104
104
|
import { handleMetrics } from './metrics.js';
|
|
105
105
|
import { jsonLog } from './log.js';
|
|
106
106
|
import { listenLocal } from './local-tls.js';
|
|
107
|
+
import { handlePullRoutes, pullContext } from './routes-pull.js';
|
|
107
108
|
import { createPeer, attachSyncWebSocket } from '../sync/index.js';
|
|
108
109
|
import { attachSignaling } from '../sync/webrtc-signaling.js';
|
|
109
110
|
import { registerDefaultHandlers } from './handlers-bootstrap.js';
|
|
@@ -148,7 +149,9 @@ export const CSP =
|
|
|
148
149
|
"default-src 'self'; " +
|
|
149
150
|
"script-src 'self' 'wasm-unsafe-eval'; " +
|
|
150
151
|
"style-src 'self'; " +
|
|
151
|
-
"img-src 'self' data: blob: https://vk.com
|
|
152
|
+
"img-src 'self' data: blob: https://vk.com " +
|
|
153
|
+
// VK's picture CDNs, for photos and stickers in VK chats (R-Z8).
|
|
154
|
+
'https://*.userapi.com https://*.vk-cdn.net https://*.vkuserphoto.ru; ' +
|
|
152
155
|
'frame-src https:; ' +
|
|
153
156
|
"connect-src 'self' https: ws: wss: http://127.0.0.1:* http://localhost:*; " +
|
|
154
157
|
"worker-src 'self'; " +
|
|
@@ -576,6 +579,7 @@ const routeOn = async (store, req, res, ctx, url, p) => {
|
|
|
576
579
|
() => ctx.clientLog?.(req, res, p, ctx) ?? false,
|
|
577
580
|
() => handleAutoImportRoutes(store, req, res, p, ctx),
|
|
578
581
|
() => handleWhatsAppRoutes(store, req, res, p, ctx),
|
|
582
|
+
() => handlePullRoutes(store, req, res, p, ctx),
|
|
579
583
|
() => handleTelegramRoutes(req, res, p, ctx),
|
|
580
584
|
() => handleMessageRoutes(store, req, res, p, url, ctx),
|
|
581
585
|
() => handleEmailGets(store, req, res, p, url, ctx),
|
|
@@ -787,15 +791,14 @@ export const startServer = async ({
|
|
|
787
791
|
telegramExtraBuild,
|
|
788
792
|
// Tests and demos pass a fake, so GitHub is never asked (R-Y53).
|
|
789
793
|
githubCheck,
|
|
790
|
-
// The hosted app and whether this
|
|
791
|
-
// (./hosted-ui.js, R-Y58 and R-Y59).
|
|
794
|
+
// The hosted app, and whether this serves its own copy (./hosted-ui.js).
|
|
792
795
|
uiUrl,
|
|
793
796
|
localUi,
|
|
794
|
-
// HTTPS on the same port for Safari (./local-tls.js, R-Y64);
|
|
795
|
-
// unless asked for, `serve` turns it on.
|
|
797
|
+
// HTTPS on the same port for Safari (./local-tls.js, R-Y64); `serve` sets it.
|
|
796
798
|
tls,
|
|
797
799
|
// Tests: a fake `(dir) => bytes|null` for the free disk space (R-Z14).
|
|
798
800
|
diskFree,
|
|
801
|
+
pulls,
|
|
799
802
|
} = {}) => {
|
|
800
803
|
const { store, resolvedArchiveDir, flush, storeHealth } = await initStore({
|
|
801
804
|
providedStore,
|
|
@@ -853,13 +856,13 @@ export const startServer = async ({
|
|
|
853
856
|
later
|
|
854
857
|
),
|
|
855
858
|
chatIndex,
|
|
859
|
+
...pullContext(store, log, pulls, later),
|
|
856
860
|
};
|
|
857
861
|
const server = createHttpServer(store, ctx, log);
|
|
858
862
|
const sockets = trackSockets(server);
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
}
|
|
863
|
+
const bus = enableHandlers
|
|
864
|
+
? registerDefaultHandlers(store, { browser: () => ctx.send })
|
|
865
|
+
: null;
|
|
863
866
|
const { wsHandle, signaling } = attachSockets(server, ctx, store, {
|
|
864
867
|
enableSync,
|
|
865
868
|
node,
|
|
@@ -903,6 +906,7 @@ export const startServer = async ({
|
|
|
903
906
|
ctx.closeAutomations().catch(() => {});
|
|
904
907
|
ctx.autoImport?.close();
|
|
905
908
|
ctx.whatsappSignIn?.stop().catch(() => {});
|
|
909
|
+
ctx.stopPulls?.();
|
|
906
910
|
ctx.chatIndex.close();
|
|
907
911
|
stopAll(telegramAccount, ctx.telegramAccounts, ctx.telegramExtras);
|
|
908
912
|
wsHandle?.close();
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inbox pulls for the networks that have no live connection of their own
|
|
3
|
+
* (R-Z8): the Connections card's "Fetch now" button and the background
|
|
4
|
+
* schedule behind it.
|
|
5
|
+
*
|
|
6
|
+
* Its routes:
|
|
7
|
+
* POST /api/pull {source} -> { ok, source, imported, lastOkAt }
|
|
8
|
+
* or { ok: false, error, code } with `code` one of
|
|
9
|
+
* unsupported | not-connected | rate-limited
|
|
10
|
+
* (HTTP 429, at most one fetch per 15 s per
|
|
11
|
+
* network, `retryAfterMs`) | the network's own code
|
|
12
|
+
* GET /api/pull/status -> { ok, sources: { <source>: { connected,
|
|
13
|
+
* lastOkAt, lastError } } }
|
|
14
|
+
*
|
|
15
|
+
* The same `createPullRunner` (../sources/pull-runner.js) serves every
|
|
16
|
+
* network: add a job to `pullJobs` to give another network a button and a
|
|
17
|
+
* schedule. VK also keeps a Long Poll running (../sources/vk-sync.js).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { createPullRunner, loadPullState } from '../sources/pull-runner.js';
|
|
21
|
+
import { createVkInbox } from '../sources/vk-sync.js';
|
|
22
|
+
import { liveOptionsFrom, pullLiveInto } from '../sources/index.js';
|
|
23
|
+
import { createUpworkLive } from '../sources/upwork.js';
|
|
24
|
+
import { createPeoplePerHourLive } from '../sources/peopleperhour.js';
|
|
25
|
+
import { fail, json, readBody } from './util.js';
|
|
26
|
+
|
|
27
|
+
const MINUTE = 60_000;
|
|
28
|
+
|
|
29
|
+
const connected = (store, source) => async () =>
|
|
30
|
+
Boolean((await liveOptionsFrom(store, source)).token);
|
|
31
|
+
|
|
32
|
+
// `live` for a network whose test double or host is given; the saved token
|
|
33
|
+
// still comes from the store.
|
|
34
|
+
const liveOf = (make, given) => (given ? make(given) : undefined);
|
|
35
|
+
|
|
36
|
+
/** The jobs: network -> { ready, run, intervalMs }. */
|
|
37
|
+
export const pullJobs = (store, { vkInbox, options = {} }) => ({
|
|
38
|
+
vk: {
|
|
39
|
+
ready: vkInbox.ready,
|
|
40
|
+
run: vkInbox.fetch,
|
|
41
|
+
intervalMs: 10 * MINUTE,
|
|
42
|
+
firstDelayMs: 30_000,
|
|
43
|
+
},
|
|
44
|
+
upwork: {
|
|
45
|
+
ready: connected(store, 'upwork'),
|
|
46
|
+
run: () =>
|
|
47
|
+
pullLiveInto(store, 'upwork', {
|
|
48
|
+
live: liveOf(createUpworkLive, options.upwork),
|
|
49
|
+
}),
|
|
50
|
+
intervalMs: 5 * MINUTE,
|
|
51
|
+
firstDelayMs: 30_000,
|
|
52
|
+
},
|
|
53
|
+
peopleperhour: {
|
|
54
|
+
ready: connected(store, 'peopleperhour'),
|
|
55
|
+
run: () =>
|
|
56
|
+
pullLiveInto(store, 'peopleperhour', {
|
|
57
|
+
live: liveOf(createPeoplePerHourLive, options.peopleperhour),
|
|
58
|
+
}),
|
|
59
|
+
intervalMs: 5 * MINUTE,
|
|
60
|
+
firstDelayMs: 30_000,
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The pull services for a server: `{ pullRunner, vkInbox, startAll, stopAll }`.
|
|
66
|
+
* `options` (tests): `{ vk: {baseUrl, fetchImpl, wait}, upwork: {baseUrl},
|
|
67
|
+
* peopleperhour: {baseUrl}, minGapMs, intervals }`; `false` turns it all off.
|
|
68
|
+
*/
|
|
69
|
+
export const createPullServices = (store, log, options = {}) => {
|
|
70
|
+
const vkInbox = createVkInbox({ store, log, ...options.vk });
|
|
71
|
+
const jobs = pullJobs(store, { vkInbox, options });
|
|
72
|
+
for (const [source, intervalMs] of Object.entries(options.intervals ?? {})) {
|
|
73
|
+
jobs[source].intervalMs = intervalMs;
|
|
74
|
+
jobs[source].firstDelayMs = intervalMs;
|
|
75
|
+
}
|
|
76
|
+
const pullRunner = createPullRunner({
|
|
77
|
+
store,
|
|
78
|
+
jobs,
|
|
79
|
+
log,
|
|
80
|
+
minGapMs: options.minGapMs,
|
|
81
|
+
});
|
|
82
|
+
return {
|
|
83
|
+
pullRunner,
|
|
84
|
+
vkInbox,
|
|
85
|
+
start: () => {
|
|
86
|
+
pullRunner.start();
|
|
87
|
+
vkInbox.start();
|
|
88
|
+
},
|
|
89
|
+
stop: () => {
|
|
90
|
+
pullRunner.stop();
|
|
91
|
+
vkInbox.stop();
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const statusOf = async (store, ctx) => {
|
|
97
|
+
const sources = {};
|
|
98
|
+
for (const source of ctx.pullRunner.sources()) {
|
|
99
|
+
const state = await loadPullState(store, source);
|
|
100
|
+
sources[source] = {
|
|
101
|
+
connected: await ctx.pullRunner.connected(source),
|
|
102
|
+
lastOkAt: state.lastOkAt ?? null,
|
|
103
|
+
lastError: state.lastError ?? null,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
return { ok: true, sources };
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const sourceOf = (body) =>
|
|
110
|
+
typeof body.source === 'string' && /^[a-z]{2,30}$/.test(body.source)
|
|
111
|
+
? body.source
|
|
112
|
+
: null;
|
|
113
|
+
|
|
114
|
+
export const handlePullRoutes = async (store, req, res, p, ctx) => {
|
|
115
|
+
if (!ctx.pullRunner) {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
if (p === '/api/pull/status' && req.method === 'GET') {
|
|
119
|
+
return json(res, 200, await statusOf(store, ctx)) ?? true;
|
|
120
|
+
}
|
|
121
|
+
if (p !== '/api/pull' || req.method !== 'POST') {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
const source = sourceOf(await readBody(req));
|
|
125
|
+
if (!source) {
|
|
126
|
+
return fail(res, 400, 'bad-request', 'a "source" is needed') ?? true;
|
|
127
|
+
}
|
|
128
|
+
const outcome = await ctx.pullRunner.fetchNow(source);
|
|
129
|
+
if (outcome.ok) {
|
|
130
|
+
return json(res, 200, outcome) ?? true;
|
|
131
|
+
}
|
|
132
|
+
const status =
|
|
133
|
+
{ 'rate-limited': 429, unsupported: 404, 'not-connected': 409 }[
|
|
134
|
+
outcome.code
|
|
135
|
+
] ?? 502;
|
|
136
|
+
return json(res, status, outcome) ?? true;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/** What a server keeps for the pulls; they start with the other `later` services. */
|
|
140
|
+
export const pullContext = (store, log, options, later) => {
|
|
141
|
+
if (options === false) {
|
|
142
|
+
return { pullRunner: null };
|
|
143
|
+
}
|
|
144
|
+
const services = createPullServices(store, log, options ?? {});
|
|
145
|
+
later.push(services.start);
|
|
146
|
+
return { pullRunner: services.pullRunner, stopPulls: services.stop };
|
|
147
|
+
};
|
|
@@ -25,7 +25,11 @@ const toArchive = (recipe, name, text, context = {}) => {
|
|
|
25
25
|
};
|
|
26
26
|
|
|
27
27
|
const parseEntry = async (provider, recipe, name, data, context) => {
|
|
28
|
-
|
|
28
|
+
// A recipe may decode its own bytes (VK's Windows-1251 pages, R-Z8).
|
|
29
|
+
const text = recipe.decode
|
|
30
|
+
? recipe.decode(name, data)
|
|
31
|
+
: data.toString('utf8');
|
|
32
|
+
const archive = toArchive(recipe, name, text, context);
|
|
29
33
|
return (await getSource(provider).parseArchive(archive)) ?? [];
|
|
30
34
|
};
|
|
31
35
|
|
|
@@ -23,6 +23,11 @@
|
|
|
23
23
|
|
|
24
24
|
import { parseCsv } from './archive.js';
|
|
25
25
|
import { chatNameFromFile } from '../whatsapp-ids.js';
|
|
26
|
+
import {
|
|
27
|
+
decodeVkHtml,
|
|
28
|
+
parseVkHtmlPage,
|
|
29
|
+
peerFromArchivePath,
|
|
30
|
+
} from '../vk-html-archive.js';
|
|
26
31
|
|
|
27
32
|
// X archives ship data as `window.YTD.<name>.part0 = [ ... ]`.
|
|
28
33
|
const ytdPayload = (text) => JSON.parse(text.replace(/^[^=[]*=\s*/, ''));
|
|
@@ -64,6 +69,14 @@ const linkedinToArchive = (_name, text) => ({
|
|
|
64
69
|
),
|
|
65
70
|
});
|
|
66
71
|
|
|
72
|
+
const vkDecode = (name, bytes) =>
|
|
73
|
+
/\.html$/i.test(name) ? decodeVkHtml(bytes) : bytes.toString('utf8');
|
|
74
|
+
|
|
75
|
+
const vkToArchive = (name, text) =>
|
|
76
|
+
/\.html$/i.test(name)
|
|
77
|
+
? parseVkHtmlPage(text, { peer: peerFromArchivePath(name) })
|
|
78
|
+
: JSON.parse(text);
|
|
79
|
+
|
|
67
80
|
export const autoImportRecipes = {
|
|
68
81
|
email: {
|
|
69
82
|
url: 'https://takeout.google.com/settings/takeout/custom/gmail',
|
|
@@ -87,7 +100,11 @@ export const autoImportRecipes = {
|
|
|
87
100
|
vk: {
|
|
88
101
|
url: 'https://vk.com/data_protection?section=rules',
|
|
89
102
|
domains: ['vk.com', 'vk.ru'],
|
|
90
|
-
|
|
103
|
+
// The JSON of konard/vk-export, or the pages of VK's own archive:
|
|
104
|
+
// messages/<peer>/messages<N>.html in Windows-1251 (R-Z8).
|
|
105
|
+
files: /(messages.*\.json|messages\/[^/]+\/messages\d*\.html)$/i,
|
|
106
|
+
decode: vkDecode,
|
|
107
|
+
toArchive: vkToArchive,
|
|
91
108
|
},
|
|
92
109
|
x: {
|
|
93
110
|
url: 'https://x.com/settings/download_your_data',
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One small runner for "fetch now" and "fetch on a schedule" of a network's
|
|
3
|
+
* inbox (R-Z8; the page's Fetch button and the background poller share it).
|
|
4
|
+
*
|
|
5
|
+
* A job is `{ ready(), run(), intervalMs }`:
|
|
6
|
+
* - `ready()` is true when the network is connected (a saved token), so a
|
|
7
|
+
* network nobody connected is never polled;
|
|
8
|
+
* - `run()` pulls and stores, returning `{ imported, ... }`. It saves any
|
|
9
|
+
* cursor itself, and only AFTER the store write succeeded, so a failed
|
|
10
|
+
* write is read again next time instead of skipped.
|
|
11
|
+
*
|
|
12
|
+
* `fetchNow(source)` is the button: at most one run per `minGapMs` (15 s) for
|
|
13
|
+
* a network, background runs counted too, and one at a time. `start()` is
|
|
14
|
+
* the schedule: each ready job runs every `intervalMs`, and after a failure
|
|
15
|
+
* the wait doubles up to `maxBackoffMs`, so a dead token or a down network
|
|
16
|
+
* is not hammered. Nothing starts before `start()`, which the server calls
|
|
17
|
+
* only once it listens. Errors come back as `{ ok: false, error, code }`
|
|
18
|
+
* with secrets masked; nothing here throws at a caller.
|
|
19
|
+
*
|
|
20
|
+
* `pull-state:<source>` keeps `lastOkAt` and a job's own cursor.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { safeMessage } from '../server/redact.js';
|
|
24
|
+
|
|
25
|
+
export const PULL_MIN_GAP_MS = 15_000;
|
|
26
|
+
const MAX_BACKOFF_MS = 60 * 60_000;
|
|
27
|
+
|
|
28
|
+
const stateId = (source) => `pull-state:${source}`;
|
|
29
|
+
// Writes of one source's state are queued, so two patches never overwrite
|
|
30
|
+
// each other (a manual fetch and the poller can overlap).
|
|
31
|
+
const queues = new WeakMap();
|
|
32
|
+
|
|
33
|
+
export const loadPullState = async (store, source) =>
|
|
34
|
+
(await store.get(stateId(source))) ?? {};
|
|
35
|
+
|
|
36
|
+
/** Merge `patch` into the source's state link; the write has finished on return. */
|
|
37
|
+
export const savePullState = (store, source, patch) => {
|
|
38
|
+
const byStore = queues.get(store) ?? new Map();
|
|
39
|
+
queues.set(store, byStore);
|
|
40
|
+
const run = async () => {
|
|
41
|
+
const current = await loadPullState(store, source);
|
|
42
|
+
const next = {
|
|
43
|
+
...current,
|
|
44
|
+
...patch,
|
|
45
|
+
id: stateId(source),
|
|
46
|
+
tokens: ['pull', 'state', source],
|
|
47
|
+
children: [],
|
|
48
|
+
};
|
|
49
|
+
await store.put(next);
|
|
50
|
+
return next;
|
|
51
|
+
};
|
|
52
|
+
const queued = (byStore.get(source) ?? Promise.resolve()).then(run, run);
|
|
53
|
+
byStore.set(
|
|
54
|
+
source,
|
|
55
|
+
queued.catch(() => {})
|
|
56
|
+
);
|
|
57
|
+
return queued;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const failure = (error, extra = {}) => ({
|
|
61
|
+
ok: false,
|
|
62
|
+
error: safeMessage(error),
|
|
63
|
+
code: error?.code && typeof error.code === 'string' ? error.code : 'failed',
|
|
64
|
+
...extra,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
export const createPullRunner = ({
|
|
68
|
+
store,
|
|
69
|
+
jobs,
|
|
70
|
+
now = Date.now,
|
|
71
|
+
minGapMs = PULL_MIN_GAP_MS,
|
|
72
|
+
maxBackoffMs = MAX_BACKOFF_MS,
|
|
73
|
+
log = null,
|
|
74
|
+
setTimer = globalThis.setTimeout,
|
|
75
|
+
clearTimer = globalThis.clearTimeout,
|
|
76
|
+
} = {}) => {
|
|
77
|
+
const lastStart = new Map();
|
|
78
|
+
const running = new Set();
|
|
79
|
+
const timers = new Map();
|
|
80
|
+
let stopped = true;
|
|
81
|
+
|
|
82
|
+
const execute = async (source) => {
|
|
83
|
+
const job = jobs[source];
|
|
84
|
+
running.add(source);
|
|
85
|
+
lastStart.set(source, now());
|
|
86
|
+
try {
|
|
87
|
+
const result = (await job.run()) ?? {};
|
|
88
|
+
const lastOkAt = new Date(now()).toISOString();
|
|
89
|
+
await savePullState(store, source, { lastOkAt, lastError: null });
|
|
90
|
+
return { ok: true, source, lastOkAt, imported: result.imported ?? 0 };
|
|
91
|
+
} catch (error) {
|
|
92
|
+
const out = failure(error, { source });
|
|
93
|
+
log?.({ level: 'warn', event: 'pull_failed', source, code: out.code });
|
|
94
|
+
await savePullState(store, source, {
|
|
95
|
+
lastError: { code: out.code, at: new Date(now()).toISOString() },
|
|
96
|
+
}).catch(() => {});
|
|
97
|
+
return out;
|
|
98
|
+
} finally {
|
|
99
|
+
running.delete(source);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const fetchNow = async (source) => {
|
|
104
|
+
const job = jobs[source];
|
|
105
|
+
if (!job) {
|
|
106
|
+
return failure(new Error(`no inbox pull for "${source}"`), {
|
|
107
|
+
code: 'unsupported',
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
if (!(await job.ready())) {
|
|
111
|
+
return failure(new Error(`${source} is not connected: save its token`), {
|
|
112
|
+
code: 'not-connected',
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
const since = now() - (lastStart.get(source) ?? -Infinity);
|
|
116
|
+
if (running.has(source) || since < minGapMs) {
|
|
117
|
+
return failure(new Error('fetched a moment ago; wait a few seconds'), {
|
|
118
|
+
code: 'rate-limited',
|
|
119
|
+
retryAfterMs: running.has(source) ? minGapMs : minGapMs - since,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return execute(source);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// One scheduled run; the number of failures in a row after it.
|
|
126
|
+
const tick = async (source, fails) => {
|
|
127
|
+
try {
|
|
128
|
+
if (!(await jobs[source].ready()) || running.has(source)) {
|
|
129
|
+
return fails;
|
|
130
|
+
}
|
|
131
|
+
return (await execute(source)).ok ? 0 : fails + 1;
|
|
132
|
+
} catch {
|
|
133
|
+
return fails + 1;
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const schedule = (source, delay, fails) => {
|
|
138
|
+
if (stopped) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const timer = setTimer(async () => {
|
|
142
|
+
const next = await tick(source, fails);
|
|
143
|
+
const wait = jobs[source].intervalMs * 2 ** Math.min(next, 10);
|
|
144
|
+
schedule(source, Math.min(wait, maxBackoffMs), next);
|
|
145
|
+
}, delay);
|
|
146
|
+
timer?.unref?.();
|
|
147
|
+
timers.set(source, timer);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
fetchNow,
|
|
152
|
+
/** Start the schedules (call after the server listens). */
|
|
153
|
+
start() {
|
|
154
|
+
stopped = false;
|
|
155
|
+
for (const [source, job] of Object.entries(jobs)) {
|
|
156
|
+
if (job.intervalMs > 0) {
|
|
157
|
+
schedule(source, job.firstDelayMs ?? job.intervalMs, 0);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
stop() {
|
|
162
|
+
stopped = true;
|
|
163
|
+
for (const timer of timers.values()) {
|
|
164
|
+
clearTimer(timer);
|
|
165
|
+
}
|
|
166
|
+
timers.clear();
|
|
167
|
+
},
|
|
168
|
+
sources: () => Object.keys(jobs),
|
|
169
|
+
connected: (source) => jobs[source].ready(),
|
|
170
|
+
};
|
|
171
|
+
};
|