@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 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 (and a relay, once there is more than one PDS), and nothing in it reaches Bluesky's network. It uses
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 bin/snowglobe.mjs app from https/apps; rerun it instead of editing.\n';
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 (bin/snowglobe.mjs app --remove ${a.name}) or give it another ending`;
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 bin/snowglobe.mjs up, which renders it`);
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);
@@ -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. Jetstream with no relay reads the first PDS itself and exits
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 went away redials it with a growing backoff; a PDS
263
- // added back under that name was not crawled again until the relay restarted (2026-10-20: no
264
- // dial in the nine minutes after it came back). So when this run made a PDS container beside a
265
- // relay that was already running, the relay restarts, and the services wait for it again.
266
- if (relayBefore.length) {
267
- const fresh = facts('SG_PDS').find((x) => ids(['-q', `pds-${x}`]).some((id) => !before.has(id)));
268
- if (fresh) {
269
- log(`Restarting the relay: pds-${fresh} is new beside it`);
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 Jetstream and TAP follow one PDS's own firehose, and the others' writes
235
- // are seen by nothing.
236
- if (services.relay === 'auto') services.relay = pds.length > 1;
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 firehose.
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
- depends_on: on.relay ? { relay: healthy } : { [svc(globe.pds[0])]: healthy },
265
- environment: {
266
- ...copy(base.base.jetstream.environment),
267
- JETSTREAM_WS_URL: `${feed.replace(/^http/, 'ws')}/xrpc/com.atproto.sync.subscribeRepos`,
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 bin/snowglobe.mjs render from ${source}; render again instead of editing.\n`;
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 bin/snowglobe.mjs up, which renders this file first.\n\n' +
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
- 'An atproto network on this machine: its own PDSs, PLC directory, relay and Jetstream. 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.',
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
- ...(on.jetstream ? { feed: '/subscribe' } : {}),
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 the globe).
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.0-rc.1",
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"
@@ -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 bin/snowglobe.mjs up.
4
- # The commented sections below add more; docs/snowglobe.md says what each one does, and
5
- # docs/examples/atmo.yml is the whole thing: three PDSes, a relay and a lexicon authority.
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. With more than one PDS the
15
- # relay turns on, so Jetstream sees every PDS's writes.
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 docs/snowglobe.md)
30
- # relay: auto # auto: on when there is more than one PDS
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; docs/your-app.md has the names.
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 }
@@ -98,17 +98,21 @@ services:
98
98
  timeout: 3s
99
99
  retries: 10
100
100
 
101
- # ── Jetstream (JSON event stream from PDS firehose) ──────────────
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
- image: ghcr.io/bluesky-social/jetstream:sha-05c89f0
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
- JETSTREAM_WS_URL: ws://pds:3000/xrpc/com.atproto.sync.subscribeRepos
110
- JETSTREAM_DATA_DIR: /data
111
- JETSTREAM_LIVENESS_TTL: 86400s
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-SHELL", "bash -c 'echo > /dev/tcp/127.0.0.1/6008'"]
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 (bin/snowglobe.mjs app).\n";
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
@@ -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
- document.getElementById("tally").textContent = line + plural(tally.writes, "write", "writes") + " in the last 24 hours.";
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
- var feed = g.feed || "wss://jetstream." + g.domain + "/subscribe";
400
- if (feed.charAt(0) === "/") feed = "wss://" + location.host + feed;
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>