@openmeet/snowglobe 0.3.0 → 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
 
@@ -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',
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",
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"
@@ -14,8 +14,8 @@ pds:
14
14
  - name: main # https://main.local.snowglobe.at
15
15
  image: ghcr.io/bluesky-social/pds:0.4.5037 # a current release
16
16
  bare_handles: true # its handles are <account>.local.snowglobe.at
17
- # A second PDS, with handles <name>.other.local.snowglobe.at. With more than one PDS the
18
- # 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.
19
19
  # - name: other
20
20
  # image: ghcr.io/bluesky-social/pds:0.4.5009
21
21
  #
@@ -30,7 +30,7 @@ services: # these are the defaults, each true or false
30
30
  slingshot: true # answers a handle's DID and PDS inside the network; needs jetstream
31
31
  pdsls: true # https://pdsls.local.snowglobe.at, a browser for the network's records
32
32
  tap: false # sends an app the events of one collection (true, or a map; see snowglobe.md above)
33
- # relay: auto # auto: on when there is more than one PDS
33
+ # relay: auto # auto: on with Jetstream, which reads it, or more than one PDS
34
34
 
35
35
  # Where every PDS resolves lexicons (NSIDs) from. Name a PDS that is in this file.
36
36
  # lexicons:
@@ -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:
@@ -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