@openmeet/snowglobe 0.3.0-rc.1 → 0.4.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 +2 -2
- package/lib/apps.mjs +3 -3
- package/lib/commands/up.mjs +20 -19
- package/lib/config.mjs +5 -3
- package/lib/render.mjs +16 -10
- package/lib/up.mjs +14 -1
- package/package.json +3 -3
- package/snowglobe.default.yml +12 -8
- package/stack/docker-compose.yml +17 -9
- package/stack/https/nginx.conf.template +1 -1
- package/stack/jetstream/Dockerfile +12 -0
- package/stack/page/index.html +16 -4
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
# Snowglobe
|
|
1
|
+
# <img src="assets/snowglobe.svg" width="40" alt=""> Snowglobe
|
|
2
2
|
|
|
3
3
|
Your own isolated [AT Protocol](https://atproto.com) network, for testing, research, and demos. A snowglobe has its own
|
|
4
|
-
PDS, PLC directory and Jetstream
|
|
4
|
+
PDS, PLC directory, relay and Jetstream, and nothing in it reaches Bluesky's network. It uses
|
|
5
5
|
https and the real network's sign-in (OAuth), so an app built for the real network runs against it
|
|
6
6
|
with no test-only code.
|
|
7
7
|
|
package/lib/apps.mjs
CHANGED
|
@@ -15,7 +15,7 @@ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from '
|
|
|
15
15
|
import { join } from 'node:path';
|
|
16
16
|
import { ROOT, globeFacts, paths } from './env.mjs';
|
|
17
17
|
|
|
18
|
-
const HEADER = '# Written by
|
|
18
|
+
const HEADER = '# Written by snowglobe app from https/apps; rerun it instead of editing.\n';
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* The first saved route whose ending is `app`, or null. app.<domain> is where the apps are, so an
|
|
@@ -25,7 +25,7 @@ export const appEnding = (apps) => apps.find((a) => a.ending === 'app') ?? null;
|
|
|
25
25
|
|
|
26
26
|
export const appEndingMessage = (file, a) =>
|
|
27
27
|
`${file}: the line "${formatApps([a]).trim()}" claims the ending app, which is where the apps are (${a.name}.app.<domain>); ` +
|
|
28
|
-
`remove the route (
|
|
28
|
+
`remove the route (snowglobe app --remove ${a.name}) or give it another ending`;
|
|
29
29
|
|
|
30
30
|
/** The routes in an apps file's text: [{ name, port, ending }], ending null when none. */
|
|
31
31
|
export function parseApps(text) {
|
|
@@ -85,7 +85,7 @@ export const writeApps = (p, apps) => put(p.apps, formatApps(apps));
|
|
|
85
85
|
export function renderRoutes({ root = ROOT, state, env = process.env } = {}) {
|
|
86
86
|
const p = paths(root, state);
|
|
87
87
|
const pageJson = join(p.globe, 'page.json');
|
|
88
|
-
if (!existsSync(pageJson)) throw new Error(`No ${pageJson}: run
|
|
88
|
+
if (!existsSync(pageJson)) throw new Error(`No ${pageJson}: run snowglobe up, which renders it`);
|
|
89
89
|
const { domain } = globeFacts({ root, state, env });
|
|
90
90
|
const apps = readApps(p);
|
|
91
91
|
const bad = appEnding(apps);
|
package/lib/commands/up.mjs
CHANGED
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
// two snowglobes need no port settings; a port variable moves one, in the environment or .env. devnet-spaces, the shared devnet, is refused (exit 3) as
|
|
42
42
|
// the project unless SG_RESET_OK=1, before anything else is done.
|
|
43
43
|
|
|
44
|
+
import { spawnSync } from 'node:child_process';
|
|
44
45
|
import { createHash } from 'node:crypto';
|
|
45
46
|
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
46
47
|
import { dirname, join, resolve } from 'node:path';
|
|
@@ -50,7 +51,7 @@ import { loadGlobe } from '../config.mjs';
|
|
|
50
51
|
import { ROOT, addName, chooseBind, dc, ensureSecrets, projectHasData, snowglobeRequest, envGet, guardProject, makePrivate, oldDataNotice, paths, renamedDefaultNotice, resolveBind, resolveDomain } from '../env.mjs';
|
|
51
52
|
import { prebuilt } from '../prebuilt.mjs';
|
|
52
53
|
import { byName, claimState, pointedAt, session } from '../state.mjs';
|
|
53
|
-
import { accountPrefixes, endingClash, mergeRoutes } from '../up.mjs';
|
|
54
|
+
import { accountPrefixes, endingClash, mergeRoutes, startedAfterRelay } from '../up.mjs';
|
|
54
55
|
import { account } from './account.mjs';
|
|
55
56
|
import { lexicons } from './lexicons.mjs';
|
|
56
57
|
import { checkedGlobe } from './check.mjs';
|
|
@@ -203,10 +204,6 @@ export async function up({ file, root = ROOT, env = process.env, log = () => {}
|
|
|
203
204
|
waitServices();
|
|
204
205
|
}
|
|
205
206
|
|
|
206
|
-
// What runs before this run: the relay, and every container, to notice a PDS this run makes.
|
|
207
|
-
const relayBefore = ids(['-q', 'relay']);
|
|
208
|
-
const before = new Set(ids(['-aq']));
|
|
209
|
-
|
|
210
207
|
// Every image the file builds is built before anything starts, so `up` never builds while
|
|
211
208
|
// containers are being made (on a runner with no images, the relay and the other built services
|
|
212
209
|
// were made, then made again by the next `up`, and Jetstream lost the relay between the two).
|
|
@@ -250,23 +247,27 @@ export async function up({ file, root = ROOT, env = process.env, log = () => {}
|
|
|
250
247
|
if (did !== composeEnv.SG_LEXICON_AUTHORITY_DID) {
|
|
251
248
|
composeEnv.SG_LEXICON_AUTHORITY_DID = did;
|
|
252
249
|
log(`Pass 2: up with the lexicon authority ${did}`);
|
|
253
|
-
// Pass 2 recreates every PDS
|
|
254
|
-
// while it is gone; on a slow machine (a CI runner) it was still restarting when compose
|
|
255
|
-
// checked it, and up failed with "jetstream is unhealthy". Stopped first, it is started
|
|
256
|
-
// again only once the PDS is healthy.
|
|
257
|
-
const running = facts('SG_SERVICES');
|
|
258
|
-
if (running.includes('jetstream') && !running.includes('relay')) compose(['stop', 'jetstream'], { stdio: 'ignore' });
|
|
250
|
+
// Pass 2 recreates every PDS, so the relay restarts after it (below).
|
|
259
251
|
await upStack();
|
|
260
252
|
}
|
|
261
253
|
|
|
262
|
-
// A relay that was running when a PDS
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
254
|
+
// A relay that was running when a PDS, or nginx in front of it, went away redials it, and after
|
|
255
|
+
// ten quick failures it waits 30 s between dials: what the PDS writes meanwhile reaches it half a
|
|
256
|
+
// minute late, or not for minutes. A PDS added back was not dialed in the nine minutes after it
|
|
257
|
+
// came back (2026-10-20), and a record written right after a first up in CI had not reached Tap
|
|
258
|
+
// 20 s later (2026-10-30). Pass 2 recreates every PDS, new certificates restart nginx, and a rerun
|
|
259
|
+
// can make a PDS. So when one of them started after the relay did, the relay restarts, and the
|
|
260
|
+
// services wait for it again.
|
|
261
|
+
if (ids(['-q', 'relay']).length) {
|
|
262
|
+
const started = (service) => {
|
|
263
|
+
const containers = ids(['-q', service]);
|
|
264
|
+
if (!containers.length) return [];
|
|
265
|
+
return (spawnSync('docker', ['inspect', '-f', '{{.State.StartedAt}}', ...containers], { encoding: 'utf8', env }).stdout ?? '').split('\n').filter(Boolean);
|
|
266
|
+
};
|
|
267
|
+
const dialed = Object.fromEntries([...facts('SG_PDS').map((x) => `pds-${x}`), 'nginx'].map((s) => [s, started(s)]));
|
|
268
|
+
const later = startedAfterRelay(started('relay'), dialed);
|
|
269
|
+
if (later) {
|
|
270
|
+
log(`Restarting the relay: ${later} started after it`);
|
|
270
271
|
compose(['restart', 'relay'], { stdio: 'ignore' });
|
|
271
272
|
waitServices();
|
|
272
273
|
}
|
package/lib/config.mjs
CHANGED
|
@@ -231,11 +231,13 @@ export function checkGlobe(doc, { baseDir } = {}) {
|
|
|
231
231
|
}
|
|
232
232
|
}
|
|
233
233
|
}
|
|
234
|
-
// Without a relay
|
|
235
|
-
//
|
|
236
|
-
|
|
234
|
+
// Without a relay TAP follows one PDS's own firehose, and the others' writes are seen by
|
|
235
|
+
// nothing. Jetstream needs the relay even with one PDS: at start it asks the relay for the
|
|
236
|
+
// network's hosts (com.atproto.sync.listHosts), which a PDS refuses.
|
|
237
|
+
if (services.relay === 'auto') services.relay = pds.length > 1 || services.jetstream;
|
|
237
238
|
else if (services.relay === false && pds.length > 1)
|
|
238
239
|
err('services.relay', 'cannot be off with more than one PDS: only one PDS would be followed');
|
|
240
|
+
else if (services.relay === false && services.jetstream) err('services.jetstream', 'needs the relay');
|
|
239
241
|
// Slingshot reads this network's Jetstream.
|
|
240
242
|
if (services.slingshot && !services.jetstream) err('services.slingshot', 'needs jetstream');
|
|
241
243
|
|
package/lib/render.mjs
CHANGED
|
@@ -46,6 +46,7 @@ const BUILT = {
|
|
|
46
46
|
'snowglobe-dns': ['stack/https/dns', ['Dockerfile', 'dns.sh']],
|
|
47
47
|
'snowglobe-proxy': ['stack/https/proxy', ['Dockerfile', 'server.mjs']],
|
|
48
48
|
'snowglobe-pdsls': ['stack/pdsls', ['Dockerfile', 'network.patch', 'server.mjs']],
|
|
49
|
+
'snowglobe-jetstream': ['stack/jetstream', ['Dockerfile']],
|
|
49
50
|
};
|
|
50
51
|
|
|
51
52
|
/** The base compose files' services, which the globe's shared services start from. */
|
|
@@ -100,7 +101,8 @@ export function renderGlobe(globe, base, { stateDir, globeDir = join(stateDir ??
|
|
|
100
101
|
// init seeds alice, bob and the invite code on the PDS with bare handles, else the first.
|
|
101
102
|
const seed = globe.pds.find((p) => p.bare_handles) ?? globe.pds[0];
|
|
102
103
|
const on = globe.services;
|
|
103
|
-
// Where Jetstream and TAP read: the relay, or with one PDS that PDS's own
|
|
104
|
+
// Where Jetstream and TAP read: the relay, or with one PDS and no relay, that PDS's own
|
|
105
|
+
// firehose (TAP only: Jetstream always has the relay, see config.mjs).
|
|
104
106
|
const feed = on.relay ? 'http://relay:2470' : `http://${svc(globe.pds[0])}:${PDS_PORT}`;
|
|
105
107
|
const copy = (o) => structuredClone(o);
|
|
106
108
|
|
|
@@ -259,13 +261,14 @@ export function renderGlobe(globe, base, { stateDir, globeDir = join(stateDir ??
|
|
|
259
261
|
}
|
|
260
262
|
|
|
261
263
|
if (on.jetstream) {
|
|
264
|
+
// Its first backfill asks each PDS for its repos at the PDS's https name, through nginx.
|
|
262
265
|
services.jetstream = {
|
|
263
266
|
...copy(base.base.jetstream),
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
267
|
+
build: './stack/jetstream',
|
|
268
|
+
image: base.tags['snowglobe-jetstream'],
|
|
269
|
+
depends_on: { relay: healthy },
|
|
270
|
+
environment: { ...copy(base.base.jetstream.environment), JETSTREAM_RELAY_URL: feed },
|
|
271
|
+
volumes: [...base.base.jetstream.volumes, GO_CA],
|
|
269
272
|
ports: [
|
|
270
273
|
'${SG_BIND:-127.0.0.1}:${SG_JETSTREAM_PORT:-6008}:6008',
|
|
271
274
|
'${SG_BIND:-127.0.0.1}:${SG_JETSTREAM_METRICS_PORT:-6009}:6009',
|
|
@@ -452,14 +455,14 @@ export function renderGlobe(globe, base, { stateDir, globeDir = join(stateDir ??
|
|
|
452
455
|
}
|
|
453
456
|
|
|
454
457
|
const header = (c) =>
|
|
455
|
-
`${c} Written by
|
|
458
|
+
`${c} Written by snowglobe render from ${source}; render again instead of editing.\n`;
|
|
456
459
|
|
|
457
460
|
const compose =
|
|
458
461
|
header('#') +
|
|
459
462
|
`# The https globe "${globe.globe}" at ${D}: ${globe.pds.map((p) => p.name).join(', ')}` +
|
|
460
463
|
(globe.grouphosts.length ? `; group hosts ${globe.grouphosts.map((g) => g.name).join(', ')}` : '') +
|
|
461
464
|
'.\n' +
|
|
462
|
-
'# Run it with
|
|
465
|
+
'# Run it with snowglobe up, which renders this file first.\n\n' +
|
|
463
466
|
// YAML 1.1 quoting, so a value such as restart's "no" stays a string to compose.
|
|
464
467
|
stringify({ services, volumes, networks: { [NETWORK]: null } }, { lineWidth: 0, nullStr: '', aliasDuplicateObjects: false, version: '1.1' });
|
|
465
468
|
|
|
@@ -514,15 +517,18 @@ export function renderGlobe(globe, base, { stateDir, globeDir = join(stateDir ??
|
|
|
514
517
|
const named = Object.entries(IMAGE_NAMES).find(([, ref]) => ref === image)?.[0];
|
|
515
518
|
return named ?? image.split('/').pop().replace(/@sha256:([0-9a-f]{12})[0-9a-f]+$/, '@sha256:$1…');
|
|
516
519
|
};
|
|
520
|
+
// What the globe is made of, as the page's first line names it: only what runs.
|
|
521
|
+
const parts = [globe.pds.length === 1 ? 'PDS' : 'PDSs', 'PLC directory', on.relay && 'relay', on.jetstream && 'Jetstream'].filter(Boolean);
|
|
517
522
|
const page = {
|
|
518
523
|
globe: globe.globe,
|
|
519
524
|
domain: D,
|
|
520
525
|
about:
|
|
521
|
-
|
|
526
|
+
`An atproto network on this machine: its own ${parts.slice(0, -1).join(', ')} and ${parts.at(-1)}. Its accounts live in its own directory, not the public one, so Bluesky cannot find anything here. An app is handed this network's addresses (snowglobe env), not Bluesky's.`,
|
|
522
527
|
accountsNote:
|
|
523
528
|
`Every account on the network, on each of its ${globe.pds.length} PDS${globe.pds.length === 1 ? '' : 's'}` +
|
|
524
529
|
(globe.grouphosts.length ? `, and every group on its group host${globe.grouphosts.length === 1 ? '' : 's'}.` : '.'),
|
|
525
|
-
|
|
530
|
+
// Without Jetstream the page has no feed to follow, and says so instead of reconnecting.
|
|
531
|
+
feed: on.jetstream ? '/subscribe' : false,
|
|
526
532
|
services: [
|
|
527
533
|
...globe.pds.map((p) => [
|
|
528
534
|
'PDS',
|
package/lib/up.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// What snowglobe up decides without Docker: the app routes the file adds, whether the globe's
|
|
2
2
|
// PDSes clash with an app's handle ending, the env names the file's accounts keep their logins
|
|
3
|
-
// under. Pure: inputs in, text or lists out (lib/commands/up.mjs runs
|
|
3
|
+
// under, whether the relay restarts. Pure: inputs in, text or lists out (lib/commands/up.mjs runs
|
|
4
|
+
// the globe).
|
|
4
5
|
|
|
5
6
|
const envName = (label) => label.toUpperCase().replace(/-/g, '_');
|
|
6
7
|
|
|
@@ -52,3 +53,15 @@ export function endingClash(pdsNames, apps) {
|
|
|
52
53
|
}
|
|
53
54
|
return null;
|
|
54
55
|
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The first of `dialed` (service name -> its containers' start times) with a container that started
|
|
59
|
+
* after the relay did (`relay`, its containers' start times), or null. The times are Docker's
|
|
60
|
+
* State.StartedAt. The relay dials each PDS through nginx, and one that was running when either
|
|
61
|
+
* went away can stop picking up what the PDS sends, so up restarts it.
|
|
62
|
+
*/
|
|
63
|
+
export function startedAfterRelay(relay, dialed) {
|
|
64
|
+
const since = Math.max(...relay.map((t) => Date.parse(t)));
|
|
65
|
+
for (const [service, times] of Object.entries(dialed)) if (times.some((t) => Date.parse(t) > since)) return service;
|
|
66
|
+
return null;
|
|
67
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openmeet/snowglobe",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Standalone, testable ATProto development network for local development and CI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"node": ">=20"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@peculiar/asn1-schema": "2.11.0",
|
|
41
|
-
"@peculiar/asn1-x509": "2.11.0",
|
|
40
|
+
"@peculiar/asn1-schema": "^2.11.0",
|
|
41
|
+
"@peculiar/asn1-x509": "^2.11.0",
|
|
42
42
|
"@peculiar/x509": "2.2.0",
|
|
43
43
|
"reflect-metadata": "0.2.2",
|
|
44
44
|
"yaml": "^2.9.1"
|
package/snowglobe.default.yml
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
# The default snowglobe: one current PDS, a PLC directory and Jetstream, all at https names, with
|
|
2
2
|
# Slingshot and PDSls (the record browser) beside them.
|
|
3
|
-
# Copy this file to snowglobe.yml (here, or in your app's repo) and run
|
|
4
|
-
#
|
|
5
|
-
#
|
|
3
|
+
# Copy this file to snowglobe.yml (here, or in your app's repo) and run snowglobe up
|
|
4
|
+
# (bin/snowglobe.mjs up from a checkout of snowglobe). The commented sections below add more.
|
|
5
|
+
# What each one does:
|
|
6
|
+
# https://github.com/OpenMeet-Team/snowglobe/blob/main/docs/snowglobe.md
|
|
7
|
+
# The whole thing, three PDSes, a relay and a lexicon authority:
|
|
8
|
+
# https://github.com/OpenMeet-Team/snowglobe/blob/main/docs/examples/atmo.yml
|
|
6
9
|
|
|
7
10
|
globe: snowglobe
|
|
8
11
|
domain: local.snowglobe.at # every name below is under it
|
|
@@ -11,8 +14,8 @@ pds:
|
|
|
11
14
|
- name: main # https://main.local.snowglobe.at
|
|
12
15
|
image: ghcr.io/bluesky-social/pds:0.4.5037 # a current release
|
|
13
16
|
bare_handles: true # its handles are <account>.local.snowglobe.at
|
|
14
|
-
# A second PDS, with handles <name>.other.local.snowglobe.at.
|
|
15
|
-
#
|
|
17
|
+
# A second PDS, with handles <name>.other.local.snowglobe.at. The relay crawls it too, so
|
|
18
|
+
# Jetstream sees its writes.
|
|
16
19
|
# - name: other
|
|
17
20
|
# image: ghcr.io/bluesky-social/pds:0.4.5009
|
|
18
21
|
#
|
|
@@ -26,8 +29,8 @@ services: # these are the defaults, each true or false
|
|
|
26
29
|
jetstream: true
|
|
27
30
|
slingshot: true # answers a handle's DID and PDS inside the network; needs jetstream
|
|
28
31
|
pdsls: true # https://pdsls.local.snowglobe.at, a browser for the network's records
|
|
29
|
-
tap: false # sends an app the events of one collection (true, or a map; see
|
|
30
|
-
# relay: auto # auto: on
|
|
32
|
+
tap: false # sends an app the events of one collection (true, or a map; see snowglobe.md above)
|
|
33
|
+
# relay: auto # auto: on with Jetstream, which reads it, or more than one PDS
|
|
31
34
|
|
|
32
35
|
# Where every PDS resolves lexicons (NSIDs) from. Name a PDS that is in this file.
|
|
33
36
|
# lexicons:
|
|
@@ -45,7 +48,8 @@ accounts: # <name>@<pds>; up makes each one
|
|
|
45
48
|
- bob@main
|
|
46
49
|
|
|
47
50
|
# Your app, reached at https://myapp.app.local.snowglobe.at, with settings from the snowglobe.
|
|
48
|
-
# `snowglobe env` hands them over
|
|
51
|
+
# `snowglobe env` hands them over. The names:
|
|
52
|
+
# https://github.com/OpenMeet-Team/snowglobe/blob/main/docs/your-app.md
|
|
49
53
|
# apps:
|
|
50
54
|
# - name: myapp
|
|
51
55
|
# local: { port: 5173 }
|
package/stack/docker-compose.yml
CHANGED
|
@@ -98,17 +98,21 @@ services:
|
|
|
98
98
|
timeout: 3s
|
|
99
99
|
retries: 10
|
|
100
100
|
|
|
101
|
-
# ── Jetstream (JSON event stream from
|
|
101
|
+
# ── Jetstream (JSON event stream from the relay's firehose) ──────
|
|
102
|
+
# Built from stack/jetstream: the pinned Jetstream, plus busybox for the health check. It reads
|
|
103
|
+
# a relay, not a PDS: at start it asks the relay for the network's hosts, which a PDS refuses.
|
|
102
104
|
jetstream:
|
|
103
|
-
|
|
105
|
+
build: ./jetstream
|
|
106
|
+
image: snowglobe-jetstream
|
|
104
107
|
restart: unless-stopped
|
|
105
|
-
depends_on:
|
|
106
|
-
pds:
|
|
107
|
-
condition: service_healthy
|
|
108
108
|
environment:
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
109
|
+
JETSTREAM_RELAY_URL: http://relay:2470
|
|
110
|
+
JETSTREAM_PLC_URL: http://plc:2582
|
|
111
|
+
# A folder of its own: a volume from an older snowglobe holds the older Jetstream's files.
|
|
112
|
+
JETSTREAM_DATA_DIR: /data/v2
|
|
113
|
+
JETSTREAM_ADDR: ":6008"
|
|
114
|
+
# Metrics, at /metrics.
|
|
115
|
+
JETSTREAM_DEBUG_ADDR: ":6009"
|
|
112
116
|
volumes:
|
|
113
117
|
- jetstream-data:/data
|
|
114
118
|
ports:
|
|
@@ -116,11 +120,15 @@ services:
|
|
|
116
120
|
- "${SG_JETSTREAM_METRICS_PORT:-${DEVNET_JETSTREAM_METRICS_PORT:-6009}}:6009"
|
|
117
121
|
networks:
|
|
118
122
|
- atproto-devnet
|
|
123
|
+
# Healthy once it serves: its archive answers 503 until then, as its streams do. The debug
|
|
124
|
+
# port's /readyz says only that it is listening.
|
|
119
125
|
healthcheck:
|
|
120
|
-
test: ["CMD
|
|
126
|
+
test: ["CMD", "/busybox", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:6008/xrpc/network.bsky.jetstream.listSegments?limit=1"]
|
|
121
127
|
interval: 5s
|
|
122
128
|
timeout: 3s
|
|
123
129
|
retries: 10
|
|
130
|
+
# A new snowglobe's first start takes a minute or two (docs/how-it-works.md).
|
|
131
|
+
start_period: 120s
|
|
124
132
|
|
|
125
133
|
# ── TAP (repo sync + backfill) ───────────────────────────────────
|
|
126
134
|
tap:
|
|
@@ -96,7 +96,7 @@ server {
|
|
|
96
96
|
listen 443 ssl;
|
|
97
97
|
server_name *.app.${SG_DOMAIN};
|
|
98
98
|
if ($snowglobe_app = "") {
|
|
99
|
-
return 404 "No app is registered at $host (
|
|
99
|
+
return 404 "No app is registered at $host (snowglobe app).\n";
|
|
100
100
|
}
|
|
101
101
|
location / {
|
|
102
102
|
proxy_pass http://$snowglobe_app;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Jetstream (github.com/bluesky-social/jetstream), the firehose as JSON for apps. It serves v2,
|
|
2
|
+
# /xrpc/network.bsky.jetstream.subscribeEvents, which contrail and @bsky/jetstream read, and the
|
|
3
|
+
# older /subscribe, which Slingshot, PDSls and the network page read.
|
|
4
|
+
# To move to another Jetstream, change the second FROM line. snowglobe up builds the new image,
|
|
5
|
+
# since the image's tag is a hash of this file.
|
|
6
|
+
# The image has no shell, so busybox comes along for the health check. It runs as root so it can
|
|
7
|
+
# write the jetstream-data volume, which Docker makes root's.
|
|
8
|
+
FROM busybox:1.37.0-musl@sha256:f6a95d611e0a8780fabdb2aec43e31275b2720dfefa3e41b16835bfc42454478 AS tools
|
|
9
|
+
|
|
10
|
+
FROM ghcr.io/bluesky-social/jetstream:v0.4.6@sha256:38e927566ed2aca19ffd2e9f3c440db74ffb404000aaf493b215dfefc6c7e54c
|
|
11
|
+
COPY --from=tools /bin/busybox /busybox
|
|
12
|
+
USER 0:0
|
package/stack/page/index.html
CHANGED
|
@@ -233,7 +233,8 @@
|
|
|
233
233
|
if (tally.people != null) parts.push(plural(tally.people, "person", "people"));
|
|
234
234
|
if (tally.groups) parts.push(plural(tally.groups, "group", "groups"));
|
|
235
235
|
var line = parts.length ? parts.join(" and ") + ". " : "";
|
|
236
|
-
|
|
236
|
+
// writes is null on a network with no feed to count them from.
|
|
237
|
+
document.getElementById("tally").textContent = line + (tally.writes == null ? "" : plural(tally.writes, "write", "writes") + " in the last 24 hours.");
|
|
237
238
|
}
|
|
238
239
|
|
|
239
240
|
function health(domain, services) {
|
|
@@ -380,6 +381,15 @@
|
|
|
380
381
|
connect();
|
|
381
382
|
}
|
|
382
383
|
|
|
384
|
+
// A network with no Jetstream has no feed: the panel says so instead of reconnecting forever.
|
|
385
|
+
function noActivity() {
|
|
386
|
+
document.getElementById("stream").style.display = "none";
|
|
387
|
+
document.getElementById("count").parentNode.style.display = "none";
|
|
388
|
+
var body = document.getElementById("events");
|
|
389
|
+
body.textContent = "";
|
|
390
|
+
add(body, el("p", { "class": "empty" }, "This network runs no Jetstream, so its writes are not followed here."));
|
|
391
|
+
}
|
|
392
|
+
|
|
383
393
|
getJSON("/snowglobe.json").then(function (g) {
|
|
384
394
|
document.title = g.globe + " · Snowglobe network";
|
|
385
395
|
document.getElementById("title").textContent = g.globe;
|
|
@@ -396,12 +406,14 @@
|
|
|
396
406
|
}
|
|
397
407
|
if (g.about) document.getElementById("about").textContent = g.about;
|
|
398
408
|
if (g.accountsNote) document.getElementById("accounts-note").textContent = g.accountsNote;
|
|
399
|
-
|
|
400
|
-
|
|
409
|
+
// feed false: the network runs no Jetstream. Unset, Jetstream is at its usual public name.
|
|
410
|
+
var feed = g.feed === false ? null : g.feed || "wss://jetstream." + g.domain + "/subscribe";
|
|
411
|
+
if (feed && feed.charAt(0) === "/") feed = "wss://" + location.host + feed;
|
|
412
|
+
if (!feed) { tally.writes = null; noActivity(); }
|
|
401
413
|
showTally();
|
|
402
414
|
health(g.domain, g.services || SERVICES);
|
|
403
415
|
// The feed names accounts by handle, so it waits for the account list.
|
|
404
|
-
accounts(g.accounts || ACCOUNTS).catch(function () {}).then(function () { activity(feed); });
|
|
416
|
+
accounts(g.accounts || ACCOUNTS).catch(function () {}).then(function () { if (feed) activity(feed); });
|
|
405
417
|
});
|
|
406
418
|
})();
|
|
407
419
|
</script>
|