fedipod 1.39.0 → 1.40.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/README.md +15 -7
- package/architecture.md +8 -0
- package/gateway.md +68 -7
- package/lib/client/masto/bridge.mjs +61 -0
- package/lib/client/masto/index.mjs +4 -1
- package/lib/client/masto/oauth.mjs +1 -1
- package/lib/client/masto/statuses.mjs +3 -0
- package/lib/connections/acctfeed.mjs +13 -9
- package/lib/connections/bskyfeed.mjs +13 -9
- package/lib/connections/tagfeed.mjs +14 -9
- package/lib/core/intake/index.mjs +193 -46
- package/lib/core/intake/verify.mjs +11 -0
- package/lib/core/lease.mjs +15 -1
- package/lib/core/pod-only.mjs +4 -0
- package/lib/core/publisher/index.mjs +2 -0
- package/lib/core/scheduled.mjs +39 -0
- package/lib/core/storage.mjs +67 -0
- package/lib/core/store.mjs +66 -4
- package/lib/gateway/account-agent.mjs +111 -0
- package/lib/gateway/copy.mjs +381 -0
- package/lib/gateway/front-core.mjs +24 -9
- package/lib/gateway/gateway-core.mjs +20 -1
- package/lib/gateway/held-mail.mjs +198 -0
- package/lib/gateway/keeper-due.mjs +39 -0
- package/lib/gateway/keeper-session.mjs +10 -0
- package/lib/gateway/keeper.mjs +72 -0
- package/lib/gateway/masto-gateway.mjs +510 -0
- package/lib/gateway/quiet.mjs +7 -2
- package/lib/gateway/relay-extras.mjs +6 -6
- package/lib/gateway/state-api.mjs +207 -0
- package/lib/pod/containers.mjs +17 -0
- package/lib/pod/transport.mjs +43 -2
- package/lib/session/README.md +5 -5
- package/lib/session/demo.html +1 -1
- package/lib/session/package.json +1 -1
- package/package.json +2 -2
- package/run-agent.mjs +4 -16
- package/scripts/stage-site.mjs +17 -4
- package/web/admin/gateway.js +14 -1
- package/web/admin/index.html +4 -0
- package/web/admin/oauth-signin.mjs +1 -1
- package/web/app/README.md +1 -1
- package/web/app/admin-facade.mjs +11 -1
- package/web/app/agent.mjs +220 -52
- package/web/app/boot.mjs +3 -0
- package/web/app/copy-mode.mjs +223 -0
- package/web/app/dist/boot.js +68 -2
- package/web/app/dist/boot.js.map +4 -4
- package/web/app/dist/sw.js +1033 -202
- package/web/app/dist/sw.js.map +4 -4
- package/web/app/sw-src.mjs +15 -53
- package/web/app/update.js +3 -2
- package/web/app/warm-start.mjs +115 -0
- package/web/app-signin/app-signin.mjs +78 -0
- package/web/app-signin/index.html +41 -0
package/README.md
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
|
|
5
5
|
FediPod gives you a Fediverse account whose data lives on a Solid pod. You
|
|
6
6
|
follow people on Mastodon, Bluesky, and other Fediverse or ATProto servers in
|
|
7
|
-
one timeline. Your posts, followers and settings
|
|
7
|
+
one timeline. Your posts, followers and settings are kept on your pod. While fedipod.net
|
|
8
|
+
keeps your account running, it works from a copy of them and writes it to your
|
|
9
|
+
pod every fifteen minutes; your signing key never leaves your pod.
|
|
8
10
|
|
|
9
11
|
The easiest way to run FediPod is to use it in any browser at https://fedipod.net. Nothing to install. Sign-up points you to a pod provider if you need a pod, then attaches a Fediverse identity to the pod you sign in with.
|
|
10
12
|
|
|
@@ -92,14 +94,16 @@ there it takes over; the first drops back to reading.
|
|
|
92
94
|
|
|
93
95
|
## What the browser version does not do
|
|
94
96
|
|
|
95
|
-
- **Scheduled posts
|
|
97
|
+
- **Scheduled posts, if you stop fedipod.net keeping your account running.**
|
|
98
|
+
It does by default, and publishes them while FediPod is closed. Turned off
|
|
99
|
+
on the manage page, nothing runs between now and the time you picked, so
|
|
96
100
|
the composer refuses one rather than dropping it later.
|
|
97
|
-
- **Notifications
|
|
98
|
-
|
|
101
|
+
- **Notifications, and other clients, if you stop fedipod.net keeping your
|
|
102
|
+
account running.** While it does (the default), any Mastodon app, phone or
|
|
103
|
+
desktop, can use your account with `fedipod.net` as its server, and an app
|
|
104
|
+
that offers notifications gets them on your phone with everything closed.
|
|
99
105
|
- **Live updates.** The client refreshes by polling.
|
|
100
106
|
- **Hosting a group.** Joining one works.
|
|
101
|
-
- **Other clients.** A phone app or desktop client has nothing on the network
|
|
102
|
-
to connect to.
|
|
103
107
|
|
|
104
108
|
For these, see [Other ways to run FediPod](#other-ways-to-run-fedipod).
|
|
105
109
|
|
|
@@ -108,6 +112,10 @@ For these, see [Other ways to run FediPod](#other-ways-to-run-fedipod).
|
|
|
108
112
|
Everything you publish and everything you read is stored on your pod. Your
|
|
109
113
|
signing key is stored there too, in a container only you can read through
|
|
110
114
|
your pod's login.
|
|
115
|
+
While fedipod.net keeps your account running, its own pod identity can read
|
|
116
|
+
that container too, so it can act for you while FediPod is closed, and it
|
|
117
|
+
works from a copy of your account's data that it writes back to your pod every
|
|
118
|
+
fifteen minutes. Your key stays on your pod only.
|
|
111
119
|
It all lives in the `fedipod` container you chose at sign-up. Your pod's
|
|
112
120
|
public type index records it, as an ActivityStreams actor, which is how
|
|
113
121
|
FediPod and other Solid apps find your account again.
|
|
@@ -119,7 +127,7 @@ not change.
|
|
|
119
127
|
|
|
120
128
|
## Other ways to use FediPod
|
|
121
129
|
|
|
122
|
-
- [fediverse-
|
|
130
|
+
- [fediverse-session](lib/session/README.md) - an ESM library that supports both Solid-based and regular Fediverse accounts with methods to login, reply, boost, etc.
|
|
123
131
|
- [FediPod Server](packages/fedipod-server/README.md): a full ActivityPub
|
|
124
132
|
server as a Community Solid Server component, giving every pod on the server
|
|
125
133
|
the option of a Fediverse account.
|
package/architecture.md
CHANGED
|
@@ -16,6 +16,14 @@ and forwards the rest to the pod inbox with a receipt. It holds no key. The
|
|
|
16
16
|
browser version always has one; the DeviceAgent may use one. Any
|
|
17
17
|
lightweight host will do, Netlify included.
|
|
18
18
|
|
|
19
|
+
A gateway can also keep a browser account running. It then acts as that
|
|
20
|
+
account's agent whenever FediPod is closed, under a pod identity of its own
|
|
21
|
+
that the owner's rules name, reading the signing key from the pod when it
|
|
22
|
+
needs it. The account's state documents live in a working copy at the
|
|
23
|
+
gateway, which FediPod in the browser, the gateway and any Mastodon app all
|
|
24
|
+
work from, and which the gateway writes to the pod every fifteen minutes. See
|
|
25
|
+
[the gateway](gateway.md).
|
|
26
|
+
|
|
19
27
|
[FediPod Server](packages/fedipod-server/README.md) puts the agent inside a
|
|
20
28
|
Community Solid Server, so anyone with a pod on that server can opt in to a
|
|
21
29
|
Fediverse account fed by the server itself.
|
package/gateway.md
CHANGED
|
@@ -6,13 +6,16 @@ each delivery's signature where the headers still exist, drops forgeries and
|
|
|
6
6
|
junk before they ever touch your pod, and passes the rest on with a receipt
|
|
7
7
|
saying it checked.
|
|
8
8
|
|
|
9
|
-
Your name, your signing key and your data
|
|
9
|
+
Your name, your signing key and your data are kept on your own pod. The gateway is
|
|
10
10
|
**keyless** — it never holds the key you sign with, so it cannot post as you,
|
|
11
11
|
read your private things, or be you anywhere. The worst a broken one can do is
|
|
12
12
|
push items into your inbox, and those still face your agent's own checks.
|
|
13
13
|
With the outbox door below, a gateway can also hand your agent a post marked as
|
|
14
14
|
yours, which your agent then signs and sends. It still holds no key, but what you
|
|
15
|
-
trust it with grows by that much.
|
|
15
|
+
trust it with grows by that much. A browser account is also kept running by
|
|
16
|
+
the gateway (below). That gives the gateway your key while it works, so it can
|
|
17
|
+
post as you, as any Fediverse server can, and your account's working data is
|
|
18
|
+
kept at the gateway too and written to your pod every fifteen minutes.
|
|
16
19
|
|
|
17
20
|
A FediPod install works without any gateway at all. Deliveries go straight to
|
|
18
21
|
your pod inbox, which holds them whether your agent is running or not.
|
|
@@ -153,10 +156,61 @@ on the setup page). Setup reads the account on the pod, keeps its state and
|
|
|
153
156
|
key there, attaches at the new gateway, and completes the move when the
|
|
154
157
|
agent first acts. See [the DeviceAgent](device-agent.md).
|
|
155
158
|
|
|
159
|
+
## While your app is closed
|
|
160
|
+
|
|
161
|
+
A browser account runs only while FediPod is open somewhere. While it is
|
|
162
|
+
closed, fedipod.net does two things for it.
|
|
163
|
+
|
|
164
|
+
**Your mail waits at the gateway.** Deliveries are kept there, not written to
|
|
165
|
+
your pod one by one, and reach your pod in batches of up to a hundred: when you
|
|
166
|
+
open FediPod, or every fifteen minutes otherwise. While FediPod is open, mail
|
|
167
|
+
goes straight to your pod as before. A DeviceAgent's mail always goes straight
|
|
168
|
+
to its pod.
|
|
169
|
+
|
|
170
|
+
**The gateway keeps your account running.** Unless you turn it off, the
|
|
171
|
+
gateway accepts your follows, tries again what failed to go out, and publishes
|
|
172
|
+
the posts you scheduled, all while FediPod is closed. Other mail waits on your
|
|
173
|
+
pod for FediPod to open, as before. It works under its own pod identity, which your app
|
|
174
|
+
names in the access rules on your FediPod folder. It reads your signing key
|
|
175
|
+
from your pod when it needs it, so it can post as you, and keeps no copy of the
|
|
176
|
+
key. **Stop keeping it running** on the manage page takes it out of the rules.
|
|
177
|
+
If fedipod.net ever changes its own pod identity, your FediPod moves your
|
|
178
|
+
account over the next time you open it, with nothing for you to do.
|
|
179
|
+
Scheduling a post is offered only while this is on, because otherwise nothing
|
|
180
|
+
would be running when the time came.
|
|
181
|
+
|
|
182
|
+
**Your account's working data is kept at the gateway.** While the gateway
|
|
183
|
+
keeps your account running, your timeline, notifications, followers, settings
|
|
184
|
+
and the rest of your FediPod state are kept at fedipod.net, and FediPod in your
|
|
185
|
+
browser, the gateway and any app you use all work from that one copy, so they
|
|
186
|
+
never disagree. fedipod.net writes what changed to your pod every fifteen
|
|
187
|
+
minutes, and everything at once when you stop it keeping your account running,
|
|
188
|
+
move your address or close it. Your signing key and the passwords of accounts
|
|
189
|
+
you connected elsewhere are never in the copy; they stay on your pod only.
|
|
190
|
+
|
|
191
|
+
## Using any Mastodon app
|
|
192
|
+
|
|
193
|
+
While the gateway keeps your account running, any Mastodon app can use it:
|
|
194
|
+
elk.zone, Ivory, Tusky, Phanpy and the rest. In the app, give `fedipod.net` as
|
|
195
|
+
your server. The app sends you to a fedipod.net page that asks for your
|
|
196
|
+
address here and signs you in at your own pod; no password is typed on
|
|
197
|
+
fedipod.net. The app then reads your timeline and notifications from your
|
|
198
|
+
account's copy at the gateway, and posts, boosts, likes and follows as you.
|
|
199
|
+
|
|
200
|
+
An app checks for new posts every minute or so while it is open; there are no
|
|
201
|
+
live updates. Accounts are made on the fedipod.net front page, with a pod, not
|
|
202
|
+
from an app.
|
|
203
|
+
|
|
204
|
+
**Notifications on your phone.** An app that offers notifications can sign up
|
|
205
|
+
for them here. A mention, a reply, a direct message, a follow, or a like, boost
|
|
206
|
+
or reaction to your own post then reaches your phone as it arrives, while
|
|
207
|
+
FediPod and the app are closed. While FediPod is open in a browser, it reads
|
|
208
|
+
your mail itself, and those arrivals are not pushed.
|
|
209
|
+
|
|
156
210
|
## Accounts that go quiet
|
|
157
211
|
|
|
158
|
-
|
|
159
|
-
|
|
212
|
+
Every delivery the gateway accepts ends up in your pod inbox, and your agent
|
|
213
|
+
reads it from there. A BrowserAgent reads only while
|
|
160
214
|
its page is open, so an account nobody opens grows on its pod without limit
|
|
161
215
|
and comes back to a drain of everything at once. fedipod.net keeps two facts
|
|
162
216
|
about each browser account — when its owner last signed in or posted, and
|
|
@@ -194,9 +248,16 @@ web address becomes a link.
|
|
|
194
248
|
|
|
195
249
|
It reads only public data to decide what concerns you: your published
|
|
196
250
|
followers and following, and a small public policy document your agent writes
|
|
197
|
-
with a mirror of your blocklist.
|
|
198
|
-
|
|
199
|
-
|
|
251
|
+
with a mirror of your blocklist. Publishing that mirror does make your
|
|
252
|
+
blocklist public, which is part of the bargain of running behind a door.
|
|
253
|
+
|
|
254
|
+
Three things go further, all for browser accounts only. Mail that waits at
|
|
255
|
+
the gateway while your app is closed, direct messages included, is stored there
|
|
256
|
+
until it reaches your pod. A gateway keeping your account running can read and
|
|
257
|
+
write everything in your FediPod folder, your signing key included, while it
|
|
258
|
+
works; nothing else on your pod is open to it. And it keeps your account's
|
|
259
|
+
working data — your timeline, notifications and direct messages among it — for
|
|
260
|
+
as long as it keeps your account running.
|
|
200
261
|
|
|
201
262
|
## Running a gateway
|
|
202
263
|
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// bridge.mjs — a fetch Request handed to code written for Node's request and
|
|
2
|
+
// response: the Mastodon facade and the admin surface. The browser worker
|
|
3
|
+
// answers its own client through it (web/app/sw-src.mjs), and the gateway
|
|
4
|
+
// answers Mastodon apps through it (lib/gateway/masto-gateway.mjs).
|
|
5
|
+
//
|
|
6
|
+
// Returns { req, res, bodyBytes, bodyText, response() }: `response()` is what
|
|
7
|
+
// the handler wrote, as a fetch Response.
|
|
8
|
+
export async function bridge(request, url, { sameOrigin = false } = {}) {
|
|
9
|
+
// Bytes, not text. A multipart upload is binary — read as text it comes back
|
|
10
|
+
// through a UTF-8 round trip that replaces every byte that is not valid UTF-8,
|
|
11
|
+
// which is most of a JPEG, so the boundary search found nothing and every
|
|
12
|
+
// media and avatar upload answered "422 file required". readBody() does
|
|
13
|
+
// `data += chunk`, which decodes a Buffer the same way it always did, so the
|
|
14
|
+
// JSON and form paths are unchanged.
|
|
15
|
+
const bodyBytes = (request.method === 'GET' || request.method === 'HEAD')
|
|
16
|
+
? null : Buffer.from(new Uint8Array(await request.arrayBuffer()));
|
|
17
|
+
const bodyText = bodyBytes ? new TextDecoder().decode(bodyBytes) : '';
|
|
18
|
+
const reqHeaders = {}; for (const [k, v] of request.headers) reqHeaders[k.toLowerCase()] = v;
|
|
19
|
+
// `host` is a forbidden header name, so a fetch Request never carries one and
|
|
20
|
+
// the loop above cannot produce it — but it is a header every real request
|
|
21
|
+
// arrives with, and the agent reads it to say where it lives. Without it the
|
|
22
|
+
// notifications `Link` header named `https://undefined/`, so a client that
|
|
23
|
+
// paged by following it (which is how a client is meant to page) walked off
|
|
24
|
+
// the origin and saw nothing past the first screen.
|
|
25
|
+
reqHeaders.host = url.host;
|
|
26
|
+
const listeners = {};
|
|
27
|
+
// The body's events fire on the next microtask. A route that reads the body
|
|
28
|
+
// may only register for them after an await or two — the facade dispatches
|
|
29
|
+
// through its area modules first — so a listener that arrives after the
|
|
30
|
+
// events have fired is given them at once, in the order it asks.
|
|
31
|
+
let fired = false;
|
|
32
|
+
const req = { method: request.method, url: url.pathname + url.search, headers: reqHeaders,
|
|
33
|
+
// Whether the caller is a page on the agent's own origin. The browser
|
|
34
|
+
// worker has established that before anything reaches the facade, and
|
|
35
|
+
// MastoApi asks (through its authorities) whether a request is the owner's
|
|
36
|
+
// own; a request at the gateway never is.
|
|
37
|
+
sameOrigin,
|
|
38
|
+
socket: { encrypted: url.protocol === 'https:' },
|
|
39
|
+
on(ev, cb) {
|
|
40
|
+
(listeners[ev] ||= []).push(cb);
|
|
41
|
+
if (fired) { if (ev === 'data' && bodyBytes?.length) cb(bodyBytes); else if (ev === 'end') cb(); }
|
|
42
|
+
return req;
|
|
43
|
+
},
|
|
44
|
+
destroy() {} };
|
|
45
|
+
queueMicrotask(() => {
|
|
46
|
+
fired = true;
|
|
47
|
+
if (bodyBytes?.length) (listeners.data || []).forEach((cb) => cb(bodyBytes));
|
|
48
|
+
(listeners.end || []).forEach((cb) => cb());
|
|
49
|
+
});
|
|
50
|
+
let status = 200; const outHeaders = {}; const chunks = [];
|
|
51
|
+
const res = {
|
|
52
|
+
writeHead(s, h) { status = s; if (h) Object.assign(outHeaders, h); return res; },
|
|
53
|
+
setHeader(k, v) { outHeaders[k] = v; }, getHeader(k) { return outHeaders[k]; },
|
|
54
|
+
write(c) { chunks.push(c); }, end(c) { if (c) chunks.push(c); },
|
|
55
|
+
};
|
|
56
|
+
return {
|
|
57
|
+
req, res, bodyBytes, bodyText,
|
|
58
|
+
get status() { return status; },
|
|
59
|
+
response: () => new Response(chunks.join(''), { status, headers: { 'content-type': 'application/json', ...outHeaders } }),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -149,7 +149,10 @@ export class MastoApi {
|
|
|
149
149
|
// A viewer-mode agent (another agent holds the drain lease) may not act —
|
|
150
150
|
// but a user acting HERE outranks the idle active agent elsewhere, so a
|
|
151
151
|
// write attempt claims the lease and proceeds. Only a failed claim 503s.
|
|
152
|
-
|
|
152
|
+
// An agent working from a copy its gateway shares (the browser build,
|
|
153
|
+
// web/app/copy-mode.mjs) asks every time, since an app at the gateway may
|
|
154
|
+
// have taken the lease without it hearing yet.
|
|
155
|
+
if ((this.agent.viewer || this.agent.copy) && req.method !== 'GET' && req.method !== 'HEAD') {
|
|
153
156
|
const took = await this.agent.requestTakeover?.();
|
|
154
157
|
if (!took) return send(503, { error: 'another agent is active for this pod — takeover failed, try again' });
|
|
155
158
|
}
|
|
@@ -45,7 +45,7 @@ const parseRedirects = (v) => (Array.isArray(v) ? v : String(v || '').split(/\s+
|
|
|
45
45
|
// The sign-in page for an account whose owner signs in at their pod: no
|
|
46
46
|
// password of ours exists, and none is asked for. The page carries the
|
|
47
47
|
// client's request in its address, signs the owner in at the pod with the
|
|
48
|
-
// fediverse-
|
|
48
|
+
// fediverse-session library, and proves that sign-in back to /oauth/authorize.
|
|
49
49
|
function sendPodSigninPage(res, client, webId, mount = '') {
|
|
50
50
|
let asking = '';
|
|
51
51
|
if (client && (client.name || client.redirect)) {
|
|
@@ -112,6 +112,9 @@ export async function handle(api, ctx) {
|
|
|
112
112
|
};
|
|
113
113
|
sched.push(entry);
|
|
114
114
|
api.store.setScheduled(sched);
|
|
115
|
+
// Whatever keeps the time for this agent hears of it (a browser app
|
|
116
|
+
// tells its gateway, which publishes it if the app has closed by then).
|
|
117
|
+
api.agent.onScheduled?.();
|
|
115
118
|
return send(200, api.scheduledJson(entry));
|
|
116
119
|
}
|
|
117
120
|
let note;
|
|
@@ -55,18 +55,22 @@ export class AcctFeed {
|
|
|
55
55
|
});
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
start
|
|
58
|
+
// `lastSweptAt` and `onSwept`: see TagFeed.start — a restarted browser
|
|
59
|
+
// worker sweeps only when the interval is due.
|
|
60
|
+
start({ lastSweptAt = 0, onSwept = null } = {}) {
|
|
59
61
|
this.stopped = false;
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}, Math.round(this.config().intervalMin * 60_000 * (0.85 + Math.random() * 0.3)));
|
|
62
|
+
const sweep = () => this.sweep()
|
|
63
|
+
.catch(e => this.log(`acctfeed: ${e.message}`))
|
|
64
|
+
.finally(() => onSwept?.(Date.now()));
|
|
65
|
+
const every = () => Math.round(this.config().intervalMin * 60_000 * (0.85 + Math.random() * 0.3));
|
|
66
|
+
const tick = (ms) => {
|
|
67
|
+
this.timer = setTimeout(() => { sweep().finally(() => { if (!this.stopped) tick(every()); }); }, ms);
|
|
67
68
|
this.timer.unref?.();
|
|
68
69
|
};
|
|
69
|
-
|
|
70
|
+
const due = lastSweptAt + this.config().intervalMin * 60_000 - Date.now();
|
|
71
|
+
if (due > 0) { tick(due); return; }
|
|
72
|
+
sweep();
|
|
73
|
+
tick(every());
|
|
70
74
|
}
|
|
71
75
|
|
|
72
76
|
stop() { this.stopped = true; clearTimeout(this.timer); }
|
|
@@ -52,18 +52,22 @@ export class BskyFeed {
|
|
|
52
52
|
return this.config();
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
start
|
|
55
|
+
// `lastSweptAt` and `onSwept`: see TagFeed.start — a restarted browser
|
|
56
|
+
// worker sweeps only when the interval is due.
|
|
57
|
+
start({ lastSweptAt = 0, onSwept = null } = {}) {
|
|
56
58
|
this.stopped = false;
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}, Math.round(this.config().intervalMin * 60_000 * (0.85 + Math.random() * 0.3)));
|
|
59
|
+
const sweep = () => this.sweep()
|
|
60
|
+
.catch(e => this.log(`bskyfeed: ${e.message}`))
|
|
61
|
+
.finally(() => onSwept?.(Date.now()));
|
|
62
|
+
const every = () => Math.round(this.config().intervalMin * 60_000 * (0.85 + Math.random() * 0.3));
|
|
63
|
+
const tick = (ms) => {
|
|
64
|
+
this.timer = setTimeout(() => { sweep().finally(() => { if (!this.stopped) tick(every()); }); }, ms);
|
|
64
65
|
this.timer.unref?.();
|
|
65
66
|
};
|
|
66
|
-
|
|
67
|
+
const due = lastSweptAt + this.config().intervalMin * 60_000 - Date.now();
|
|
68
|
+
if (due > 0) { tick(due); return; }
|
|
69
|
+
sweep();
|
|
70
|
+
tick(every());
|
|
67
71
|
}
|
|
68
72
|
|
|
69
73
|
stop() { this.stopped = true; clearTimeout(this.timer); }
|
|
@@ -72,20 +72,25 @@ export class TagFeed {
|
|
|
72
72
|
return this.config();
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
|
|
75
|
+
// `lastSweptAt` and `onSwept` are for a browser worker the browser stops and
|
|
76
|
+
// restarts: it keeps the time of its last sweep, so a restart sweeps only
|
|
77
|
+
// when the interval is due rather than on every wake.
|
|
78
|
+
start({ lastSweptAt = 0, onSwept = null } = {}) {
|
|
76
79
|
this.stopped = false; // restartable, the same way Intake.start is
|
|
77
|
-
|
|
80
|
+
const sweep = () => this.sweep()
|
|
81
|
+
.catch(e => this.log(`tagfeed: ${e.message}`))
|
|
82
|
+
.finally(() => onSwept?.(Date.now()));
|
|
78
83
|
// Jittered and self-scheduling: every agent polling the same instance on
|
|
79
84
|
// the same 15-minute boundary is a beat nobody asked for.
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
.catch(e => this.log(`tagfeed: ${e.message}`))
|
|
84
|
-
.finally(() => { if (!this.stopped) tick(); });
|
|
85
|
-
}, Math.round(this.config().intervalMin * 60_000 * (0.85 + Math.random() * 0.3)));
|
|
85
|
+
const every = () => Math.round(this.config().intervalMin * 60_000 * (0.85 + Math.random() * 0.3));
|
|
86
|
+
const tick = (ms) => {
|
|
87
|
+
this.timer = setTimeout(() => { sweep().finally(() => { if (!this.stopped) tick(every()); }); }, ms);
|
|
86
88
|
this.timer.unref?.();
|
|
87
89
|
};
|
|
88
|
-
|
|
90
|
+
const due = lastSweptAt + this.config().intervalMin * 60_000 - Date.now();
|
|
91
|
+
if (due > 0) { tick(due); return; }
|
|
92
|
+
sweep();
|
|
93
|
+
tick(every());
|
|
89
94
|
}
|
|
90
95
|
|
|
91
96
|
// The flag is what makes this stick. Clearing the timer only cancels a sweep
|