@rocksky/sdk 0.10.1 → 0.11.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/dist/agent.d.ts CHANGED
@@ -2,6 +2,55 @@ import { PasswordSession } from "@atcute/password-session";
2
2
  import type { AlbumRecord, ArtistRecord, ActorTrackView, ScrobbleRecord, ShoutGif, SongRecord } from "./generated/types.js";
3
3
  import type { IndexStats, RockskyIndex } from "./dedup.js";
4
4
  import { type JetstreamOptions } from "./jetstream.js";
5
+ /**
6
+ * The highest sustained writes-per-hour that still fits Bluesky's write-point
7
+ * budget. On the official Bluesky PDS this is a hard ceiling the Agent will not
8
+ * let any caller exceed; self-hosted PDSes may allow more (or none).
9
+ */
10
+ export declare const MAX_SAFE_WRITES_PER_HOUR: number;
11
+ export declare const DEFAULT_MATCH_SONG_PER_HOUR: number;
12
+ /** Options for {@link Agent.configureRateLimit}. */
13
+ export interface RateLimitOptions {
14
+ /**
15
+ * Target writes (createRecord/putRecord/deleteRecord) per hour. Omitted →
16
+ * {@link MAX_SAFE_WRITES_PER_HOUR}. On the official Bluesky PDS this is
17
+ * clamped to the safe ceiling; on a self-hosted PDS it is honored as given.
18
+ * Ignored when `disabled` is true.
19
+ */
20
+ writesPerHour?: number;
21
+ /**
22
+ * Turn the client-side *write* throttle off entirely. Honored on self-hosted
23
+ * PDSes (useful when you run your own PDS with its own limits). IGNORED —
24
+ * forced back on at the safe rate — when the account lives on the official
25
+ * Bluesky PDS (*.bsky.network), whose budget is enforced server-side.
26
+ *
27
+ * NOTE: this never affects the Rocksky AppView `matchSong` throttle, which is
28
+ * always enforced (see {@link RateLimitOptions.matchSongPerHour}).
29
+ */
30
+ disabled?: boolean;
31
+ /**
32
+ * Target Rocksky AppView `matchSong` calls per hour. ALWAYS enforced — a
33
+ * self-hosted PDS grants no extra AppView capacity — so `disabled` never turns
34
+ * it off; this only tunes the rate. Omitted → keep the current value
35
+ * (default {@link DEFAULT_MATCH_SONG_PER_HOUR}). Non-positive values are ignored.
36
+ */
37
+ matchSongPerHour?: number;
38
+ }
39
+ /** The effective throttle state after {@link Agent.configureRateLimit} applies policy. */
40
+ export interface RateLimitState {
41
+ /** Whether the *write* throttle is active. */
42
+ enabled: boolean;
43
+ /** Effective writes/hour cap (Infinity when disabled). */
44
+ writesPerHour: number;
45
+ /** True when `disabled` was requested but overridden by the bsky.network guard. */
46
+ forcedOn: boolean;
47
+ /** True when a requested `writesPerHour` was clamped to the safe ceiling. */
48
+ capped: boolean;
49
+ /** The resolved PDS host the decision was based on. */
50
+ pdsHost: string;
51
+ /** Effective Rocksky AppView matchSong rate — always enforced, never disabled. */
52
+ matchSongPerHour: number;
53
+ }
5
54
  /** Input for {@link Agent.scrobble} (createdAt defaults to now). */
6
55
  export type ScrobbleInput = Omit<ScrobbleRecord, "createdAt"> & {
7
56
  createdAt?: string;
@@ -42,7 +91,31 @@ export declare class Agent {
42
91
  readonly session: PasswordSession;
43
92
  private pds;
44
93
  private idx?;
94
+ private writeGate;
95
+ private matchGate;
96
+ private matchSongPerHour;
97
+ /** Lower-case hostname of the account's resolved PDS (e.g. "pds.example.com"). */
98
+ readonly pdsHost: string;
45
99
  private constructor();
100
+ /**
101
+ * Whether the account lives on the official Bluesky PDS (*.bsky.network).
102
+ * Its write budget is enforced server-side, so the client-side throttle can
103
+ * never be disabled for these hosts.
104
+ */
105
+ get isOfficialBlueskyPds(): boolean;
106
+ /**
107
+ * Configure the client-side write throttle and return the effective state.
108
+ *
109
+ * Policy — the *.bsky.network guard is authoritative and cannot be bypassed:
110
+ * - `disabled: true` turns the throttle off on a self-hosted PDS, but on the
111
+ * official Bluesky PDS it is ignored and the throttle stays on at the safe
112
+ * rate (`forcedOn: true`).
113
+ * - `writesPerHour` is honored as given on a self-hosted PDS, but clamped to
114
+ * {@link MAX_SAFE_WRITES_PER_HOUR} on the official Bluesky PDS
115
+ * (`capped: true` when clamped).
116
+ * - Omitting both enables the throttle at the safe default rate.
117
+ */
118
+ configureRateLimit(opts?: RateLimitOptions): RateLimitState;
46
119
  /**
47
120
  * Resolve the account's PDS, authenticate with an app password, and return an
48
121
  * Agent. `identifier` is a handle or DID.
@@ -1 +1 @@
1
- {"version":3,"file":"agent.d.ts","sourceRoot":"","sources":["../src/agent.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"agent.d.ts","sourceRoot":"","sources":["../src/agent.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=agent.ratelimit.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"agent.ratelimit.test.d.ts","sourceRoot":"","sources":["../src/agent.ratelimit.test.ts"],"names":[],"mappings":""}
package/dist/index.d.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  export { RockskyClient, DEFAULT_APPVIEW, Interval } from "./client.js";
12
12
  export type { DateInterval } from "./client.js";
13
13
  export { RockskyLibrary } from "./library.js";
14
- export { Agent, type ScrobbleInput, type ScrobbleMatchInput, type SongInput, type AlbumInput, type ArtistInput, } from "./agent.js";
14
+ export { Agent, MAX_SAFE_WRITES_PER_HOUR, DEFAULT_MATCH_SONG_PER_HOUR, type ScrobbleInput, type ScrobbleMatchInput, type SongInput, type AlbumInput, type ArtistInput, type RateLimitOptions, type RateLimitState, } from "./agent.js";
15
15
  export { RockskyIndex, totalIndexed, type IndexStats } from "./dedup.js";
16
16
  export { runJetstream, DEFAULT_JETSTREAM_SERVERS, type JetstreamOptions } from "./jetstream.js";
17
17
  export { RemotePlayer, DEFAULT_REMOTE_WS, type RemotePlayerOptions, type RemotePlayerHandlers, type RemoteNowPlaying, type RemoteQueueItem, type EnqueueCommand, } from "./remote-player.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,EAAE,aAAa,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AACvE,YAAY,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAChD,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,EACL,KAAK,EACL,KAAK,aAAa,EAClB,KAAK,kBAAkB,EACvB,KAAK,SAAS,EACd,KAAK,UAAU,EACf,KAAK,WAAW,GACjB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,KAAK,UAAU,EAAE,MAAM,YAAY,CAAC;AACzE,OAAO,EAAE,YAAY,EAAE,yBAAyB,EAAE,KAAK,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAChG,OAAO,EACL,YAAY,EACZ,iBAAiB,EACjB,KAAK,mBAAmB,EACxB,KAAK,oBAAoB,EACzB,KAAK,gBAAgB,EACrB,KAAK,eAAe,EACpB,KAAK,cAAc,GACpB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EACL,gBAAgB,EAChB,KAAK,uBAAuB,EAC5B,KAAK,sBAAsB,EAC3B,KAAK,YAAY,EACjB,KAAK,YAAY,GAClB,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AAC5D,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,mBAAmB,sBAAsB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,EAAE,aAAa,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AACvE,YAAY,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAChD,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,EACL,KAAK,EACL,wBAAwB,EACxB,2BAA2B,EAC3B,KAAK,aAAa,EAClB,KAAK,kBAAkB,EACvB,KAAK,SAAS,EACd,KAAK,UAAU,EACf,KAAK,WAAW,EAChB,KAAK,gBAAgB,EACrB,KAAK,cAAc,GACpB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,KAAK,UAAU,EAAE,MAAM,YAAY,CAAC;AACzE,OAAO,EAAE,YAAY,EAAE,yBAAyB,EAAE,KAAK,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAChG,OAAO,EACL,YAAY,EACZ,iBAAiB,EACjB,KAAK,mBAAmB,EACxB,KAAK,oBAAoB,EACzB,KAAK,gBAAgB,EACrB,KAAK,eAAe,EACpB,KAAK,cAAc,GACpB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EACL,gBAAgB,EAChB,KAAK,uBAAuB,EAC5B,KAAK,sBAAsB,EAC3B,KAAK,YAAY,EACjB,KAAK,YAAY,GAClB,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AAC5D,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,mBAAmB,sBAAsB,CAAC"}
package/dist/index.js CHANGED
@@ -626,6 +626,38 @@ function actorResolver() {
626
626
  function nowISO() {
627
627
  return new Date().toISOString();
628
628
  }
629
+ function hostOf(url) {
630
+ try {
631
+ return new URL(url).hostname.toLowerCase();
632
+ } catch {
633
+ return "";
634
+ }
635
+ }
636
+ var PDS_WRITE_POINT_BUDGET_PER_HOUR = 5000;
637
+ var POINTS_PER_WRITE = 3;
638
+ var SAFETY_MARGIN = 0.9;
639
+ var MAX_SAFE_WRITES_PER_HOUR = Math.floor(PDS_WRITE_POINT_BUDGET_PER_HOUR * SAFETY_MARGIN / POINTS_PER_WRITE);
640
+ var APPVIEW_REQUEST_LIMIT = 1000;
641
+ var APPVIEW_WINDOW_SECONDS = 30;
642
+ var DEFAULT_MATCH_SONG_PER_HOUR = Math.floor(APPVIEW_REQUEST_LIMIT * SAFETY_MARGIN / APPVIEW_WINDOW_SECONDS * 3600);
643
+
644
+ class RateGate {
645
+ nextAt = 0;
646
+ minIntervalMs = 0;
647
+ setRate(writesPerHour) {
648
+ this.minIntervalMs = writesPerHour && writesPerHour > 0 ? Math.ceil(3600000 / writesPerHour) : 0;
649
+ }
650
+ async take() {
651
+ if (this.minIntervalMs <= 0)
652
+ return;
653
+ const now = Date.now();
654
+ const at = Math.max(now, this.nextAt);
655
+ this.nextAt = at + this.minIntervalMs;
656
+ const delay = at - now;
657
+ if (delay > 0)
658
+ await new Promise((r) => setTimeout(r, delay));
659
+ }
660
+ }
629
661
 
630
662
  class Agent {
631
663
  rpc;
@@ -633,11 +665,51 @@ class Agent {
633
665
  session;
634
666
  pds;
635
667
  idx;
668
+ writeGate = new RateGate;
669
+ matchGate = new RateGate;
670
+ matchSongPerHour = DEFAULT_MATCH_SONG_PER_HOUR;
671
+ pdsHost;
636
672
  constructor(rpc, did, session, pds) {
637
673
  this.rpc = rpc;
638
674
  this.did = did;
639
675
  this.session = session;
640
676
  this.pds = pds;
677
+ this.pdsHost = hostOf(pds);
678
+ this.matchGate.setRate(this.matchSongPerHour);
679
+ }
680
+ get isOfficialBlueskyPds() {
681
+ return this.pdsHost === "bsky.network" || this.pdsHost.endsWith(".bsky.network");
682
+ }
683
+ configureRateLimit(opts = {}) {
684
+ if (opts.matchSongPerHour !== undefined && opts.matchSongPerHour > 0) {
685
+ this.matchSongPerHour = opts.matchSongPerHour;
686
+ this.matchGate.setRate(this.matchSongPerHour);
687
+ }
688
+ const official = this.isOfficialBlueskyPds;
689
+ const state = (partial) => ({
690
+ ...partial,
691
+ pdsHost: this.pdsHost,
692
+ matchSongPerHour: this.matchSongPerHour
693
+ });
694
+ if (opts.disabled) {
695
+ if (official) {
696
+ const writesPerHour2 = Math.min(opts.writesPerHour ?? MAX_SAFE_WRITES_PER_HOUR, MAX_SAFE_WRITES_PER_HOUR);
697
+ this.writeGate.setRate(writesPerHour2);
698
+ return state({ enabled: true, writesPerHour: writesPerHour2, forcedOn: true, capped: false });
699
+ }
700
+ this.writeGate.setRate(null);
701
+ return state({ enabled: false, writesPerHour: Infinity, forcedOn: false, capped: false });
702
+ }
703
+ let writesPerHour = opts.writesPerHour ?? MAX_SAFE_WRITES_PER_HOUR;
704
+ if (!(writesPerHour > 0))
705
+ writesPerHour = MAX_SAFE_WRITES_PER_HOUR;
706
+ let capped = false;
707
+ if (official && writesPerHour > MAX_SAFE_WRITES_PER_HOUR) {
708
+ writesPerHour = MAX_SAFE_WRITES_PER_HOUR;
709
+ capped = true;
710
+ }
711
+ this.writeGate.setRate(writesPerHour);
712
+ return state({ enabled: true, writesPerHour, forcedOn: false, capped });
641
713
  }
642
714
  static async login(identifier, password) {
643
715
  const actor = await actorResolver().resolve(identifier);
@@ -664,6 +736,7 @@ class Agent {
664
736
  return runJetstream(this.idx, this.did, opts);
665
737
  }
666
738
  async create(collection, record) {
739
+ await this.writeGate.take();
667
740
  const res = await this.rpc.post("com.atproto.repo.createRecord", {
668
741
  input: { repo: this.did, collection, record: { ...record, $type: collection } }
669
742
  });
@@ -672,6 +745,7 @@ class Agent {
672
745
  return res.data.uri;
673
746
  }
674
747
  async putRecord(collection, rkey, record) {
748
+ await this.writeGate.take();
675
749
  const res = await this.rpc.post("com.atproto.repo.putRecord", {
676
750
  input: { repo: this.did, collection, rkey, record: { ...record, $type: collection } }
677
751
  });
@@ -680,6 +754,7 @@ class Agent {
680
754
  return res.data.uri;
681
755
  }
682
756
  async delete(collection, rkey) {
757
+ await this.writeGate.take();
683
758
  const res = await this.rpc.post("com.atproto.repo.deleteRecord", {
684
759
  input: { repo: this.did, collection, rkey }
685
760
  });
@@ -730,6 +805,7 @@ class Agent {
730
805
  async scrobbleMatch(input, appview) {
731
806
  const { title, artist, album, mbId, isrc, timestamp } = input;
732
807
  const { RockskyClient: RockskyClient2 } = await Promise.resolve().then(() => (init_client(), exports_client));
808
+ await this.matchGate.take();
733
809
  const m = await new RockskyClient2(appview).matchSong(title, artist, mbId, isrc);
734
810
  const s = (k) => m && typeof m[k] === "string" ? m[k] : undefined;
735
811
  const n = (k) => m && typeof m[k] === "number" ? m[k] : undefined;
@@ -1541,8 +1617,10 @@ export {
1541
1617
  RockskyClient,
1542
1618
  RemotePlayer,
1543
1619
  RemoteController,
1620
+ MAX_SAFE_WRITES_PER_HOUR,
1544
1621
  Interval,
1545
1622
  DEFAULT_REMOTE_WS,
1623
+ DEFAULT_MATCH_SONG_PER_HOUR,
1546
1624
  DEFAULT_JETSTREAM_SERVERS,
1547
1625
  DEFAULT_APPVIEW,
1548
1626
  Agent
@@ -0,0 +1,3 @@
1
+ export { RemotePlayer, DEFAULT_REMOTE_WS, type RemotePlayerOptions, type RemotePlayerHandlers, type RemoteNowPlaying, type RemoteQueueItem, type EnqueueCommand, } from "./remote-player.js";
2
+ export { RemoteController, type RemoteControllerOptions, type RemoteControllerEvents, type RemoteDevice, type RemoteStatus, } from "./remote-controller.js";
3
+ //# sourceMappingURL=remote.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"remote.d.ts","sourceRoot":"","sources":["../src/remote.ts"],"names":[],"mappings":"AAOA,OAAO,EACL,YAAY,EACZ,iBAAiB,EACjB,KAAK,mBAAmB,EACxB,KAAK,oBAAoB,EACzB,KAAK,gBAAgB,EACrB,KAAK,eAAe,EACpB,KAAK,cAAc,GACpB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EACL,gBAAgB,EAChB,KAAK,uBAAuB,EAC5B,KAAK,sBAAsB,EAC3B,KAAK,YAAY,EACjB,KAAK,YAAY,GAClB,MAAM,wBAAwB,CAAC"}
package/dist/remote.js ADDED
@@ -0,0 +1,529 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __returnValue = (v) => v;
3
+ function __exportSetter(name, newValue) {
4
+ this[name] = __returnValue.bind(null, newValue);
5
+ }
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, {
9
+ get: all[name],
10
+ enumerable: true,
11
+ configurable: true,
12
+ set: __exportSetter.bind(all, name)
13
+ });
14
+ };
15
+ var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
16
+
17
+ // src/remote-player.ts
18
+ var DEFAULT_REMOTE_WS = "wss://api.rocksky.app/ws";
19
+
20
+ class RemotePlayer {
21
+ opts;
22
+ ws = null;
23
+ deviceId = "";
24
+ stopped = false;
25
+ heartbeat;
26
+ reconnectTimer;
27
+ url;
28
+ heartbeatMs;
29
+ reconnectMs;
30
+ getToken;
31
+ debug;
32
+ handlers = {};
33
+ lastTrack = null;
34
+ lastStatus = null;
35
+ lastQueue = null;
36
+ constructor(opts) {
37
+ this.opts = opts;
38
+ this.url = opts.url ?? DEFAULT_REMOTE_WS;
39
+ this.heartbeatMs = opts.heartbeatMs ?? 1e4;
40
+ this.reconnectMs = opts.reconnectMs ?? 3000;
41
+ this.getToken = typeof opts.token === "function" ? opts.token : () => opts.token;
42
+ this.debug = opts.debug ?? (() => {});
43
+ }
44
+ on(event, handler) {
45
+ this.handlers[event] = handler;
46
+ return this;
47
+ }
48
+ get id() {
49
+ return this.deviceId;
50
+ }
51
+ connect() {
52
+ this.stopped = false;
53
+ this.open();
54
+ }
55
+ disconnect() {
56
+ this.stopped = true;
57
+ if (this.reconnectTimer)
58
+ clearTimeout(this.reconnectTimer);
59
+ if (this.heartbeat)
60
+ clearInterval(this.heartbeat);
61
+ try {
62
+ this.ws?.close();
63
+ } catch {}
64
+ this.ws = null;
65
+ }
66
+ setNowPlaying(track) {
67
+ this.lastTrack = track;
68
+ this.send({
69
+ type: "message",
70
+ device_id: this.deviceId,
71
+ token: this.getToken(),
72
+ data: {
73
+ type: "track",
74
+ title: track.title,
75
+ artist: track.artist,
76
+ album: track.album,
77
+ album_artist: track.albumArtist ?? track.artist,
78
+ length: track.durationMs ?? 0,
79
+ elapsed: track.elapsedMs ?? 0,
80
+ duration_ms: track.durationMs ?? 0,
81
+ album_art: track.albumArt,
82
+ is_playing: track.isPlaying ?? true,
83
+ device_name: this.opts.name
84
+ }
85
+ });
86
+ }
87
+ setStatus(status) {
88
+ const code = status === "playing" ? 1 : status === "paused" ? 2 : 0;
89
+ this.lastStatus = code;
90
+ this.send({
91
+ type: "message",
92
+ device_id: this.deviceId,
93
+ token: this.getToken(),
94
+ data: { type: "status", status: code }
95
+ });
96
+ }
97
+ setQueue(items, index) {
98
+ this.lastQueue = { items, index };
99
+ this.send({
100
+ type: "message",
101
+ device_id: this.deviceId,
102
+ token: this.getToken(),
103
+ data: {
104
+ type: "queue",
105
+ index,
106
+ queue: items.map((t) => ({
107
+ uploadId: t.uploadId,
108
+ trackId: t.trackId,
109
+ title: t.title,
110
+ artist: t.artist,
111
+ album: t.album,
112
+ album_artist: t.albumArtist,
113
+ album_art: t.albumArt,
114
+ duration: t.durationMs,
115
+ song_uri: t.songUri,
116
+ album_uri: t.albumUri,
117
+ track_number: t.trackNumber
118
+ }))
119
+ }
120
+ });
121
+ }
122
+ send(payload) {
123
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
124
+ try {
125
+ this.ws.send(JSON.stringify(payload));
126
+ } catch (e) {
127
+ this.debug("send error", e);
128
+ }
129
+ }
130
+ }
131
+ open() {
132
+ if (this.stopped)
133
+ return;
134
+ const token = this.getToken();
135
+ if (!token) {
136
+ this.reconnectTimer = setTimeout(() => this.open(), this.reconnectMs);
137
+ return;
138
+ }
139
+ let ws;
140
+ try {
141
+ ws = new WebSocket(this.url);
142
+ } catch (e) {
143
+ this.debug("connect failed", e);
144
+ this.reconnectTimer = setTimeout(() => this.open(), this.reconnectMs);
145
+ return;
146
+ }
147
+ this.ws = ws;
148
+ ws.onopen = () => {
149
+ this.debug("connected");
150
+ this.send({ type: "register", clientName: this.opts.name, token: this.getToken() });
151
+ if (this.heartbeat)
152
+ clearInterval(this.heartbeat);
153
+ this.heartbeat = setInterval(() => {
154
+ if (ws.readyState === WebSocket.OPEN)
155
+ ws.send("ping");
156
+ }, this.heartbeatMs);
157
+ };
158
+ ws.onmessage = (ev) => {
159
+ if (ev.data === "pong")
160
+ return;
161
+ let msg;
162
+ try {
163
+ msg = JSON.parse(ev.data);
164
+ } catch {
165
+ return;
166
+ }
167
+ this.handle(msg);
168
+ };
169
+ ws.onerror = () => {
170
+ try {
171
+ ws.close();
172
+ } catch {}
173
+ };
174
+ ws.onclose = () => {
175
+ this.debug("disconnected");
176
+ if (this.heartbeat)
177
+ clearInterval(this.heartbeat);
178
+ if (this.ws === ws)
179
+ this.ws = null;
180
+ this.deviceId = "";
181
+ if (!this.stopped) {
182
+ if (this.reconnectTimer)
183
+ clearTimeout(this.reconnectTimer);
184
+ this.reconnectTimer = setTimeout(() => this.open(), this.reconnectMs);
185
+ }
186
+ };
187
+ }
188
+ handle(msg) {
189
+ if (msg.status === "registered" && typeof msg.deviceId === "string") {
190
+ this.deviceId = msg.deviceId;
191
+ this.debug("registered", this.deviceId);
192
+ this.resync();
193
+ return;
194
+ }
195
+ if (msg.type === "command") {
196
+ this.dispatch(msg);
197
+ return;
198
+ }
199
+ }
200
+ dispatch(msg) {
201
+ const h = this.handlers;
202
+ switch (msg.action) {
203
+ case "play":
204
+ h.play?.();
205
+ break;
206
+ case "pause":
207
+ h.pause?.();
208
+ break;
209
+ case "next":
210
+ h.next?.();
211
+ break;
212
+ case "previous":
213
+ h.previous?.();
214
+ break;
215
+ case "seek": {
216
+ const a = msg.args;
217
+ const pos = typeof a === "number" ? a : a?.position ?? 0;
218
+ h.seek?.(pos);
219
+ break;
220
+ }
221
+ case "queue_jump":
222
+ h.queueJump?.(msg.args?.index ?? 0);
223
+ break;
224
+ case "queue_remove":
225
+ h.queueRemove?.(msg.args?.index ?? 0);
226
+ break;
227
+ case "enqueue": {
228
+ const a = msg.args ?? {};
229
+ h.enqueue?.({
230
+ tracks: (a.tracks ?? []).map(descriptorToItem),
231
+ mode: a.mode ?? "now",
232
+ shuffle: !!a.shuffle,
233
+ startIndex: a.startIndex ?? 0
234
+ });
235
+ break;
236
+ }
237
+ default:
238
+ this.debug("unknown command", msg.action);
239
+ }
240
+ }
241
+ resync() {
242
+ if (this.lastTrack)
243
+ this.setNowPlaying(this.lastTrack);
244
+ if (this.lastStatus !== null) {
245
+ this.send({
246
+ type: "message",
247
+ device_id: this.deviceId,
248
+ token: this.getToken(),
249
+ data: { type: "status", status: this.lastStatus }
250
+ });
251
+ }
252
+ if (this.lastQueue)
253
+ this.setQueue(this.lastQueue.items, this.lastQueue.index);
254
+ }
255
+ }
256
+ function descriptorToItem(d) {
257
+ return {
258
+ uploadId: d.uploadId,
259
+ trackId: d.trackId,
260
+ title: d.title ?? "",
261
+ artist: d.artist ?? "",
262
+ album: d.album,
263
+ albumArtist: d.album_artist,
264
+ albumArt: d.album_art,
265
+ durationMs: d.duration,
266
+ songUri: d.song_uri,
267
+ albumUri: d.album_uri,
268
+ trackNumber: d.track_number
269
+ };
270
+ }
271
+ // src/remote-controller.ts
272
+ class RemoteController {
273
+ opts;
274
+ ws = null;
275
+ stopped = false;
276
+ heartbeat;
277
+ reconnectTimer;
278
+ url;
279
+ heartbeatMs;
280
+ reconnectMs;
281
+ getToken;
282
+ debug;
283
+ handlers = {};
284
+ constructor(opts) {
285
+ this.opts = opts;
286
+ this.url = opts.url ?? DEFAULT_REMOTE_WS;
287
+ this.heartbeatMs = opts.heartbeatMs ?? 1e4;
288
+ this.reconnectMs = opts.reconnectMs ?? 3000;
289
+ this.getToken = typeof opts.token === "function" ? opts.token : () => opts.token;
290
+ this.debug = opts.debug ?? (() => {});
291
+ }
292
+ on(event, handler) {
293
+ this.handlers[event] = handler;
294
+ return this;
295
+ }
296
+ connect() {
297
+ this.stopped = false;
298
+ this.open();
299
+ }
300
+ disconnect() {
301
+ this.stopped = true;
302
+ if (this.reconnectTimer)
303
+ clearTimeout(this.reconnectTimer);
304
+ if (this.heartbeat)
305
+ clearInterval(this.heartbeat);
306
+ try {
307
+ this.ws?.close();
308
+ } catch {}
309
+ this.ws = null;
310
+ }
311
+ setPrimary(deviceId) {
312
+ this.send({ type: "set_primary", device_id: deviceId, token: this.getToken() });
313
+ }
314
+ play(target) {
315
+ this.command("play", target);
316
+ }
317
+ pause(target) {
318
+ this.command("pause", target);
319
+ }
320
+ next(target) {
321
+ this.command("next", target);
322
+ }
323
+ previous(target) {
324
+ this.command("previous", target);
325
+ }
326
+ seek(target, positionMs) {
327
+ this.command("seek", target, { position: positionMs });
328
+ }
329
+ queueJump(target, index) {
330
+ this.command("queue_jump", target, { index });
331
+ }
332
+ queueRemove(target, index) {
333
+ this.command("queue_remove", target, { index });
334
+ }
335
+ enqueue(target, tracks, mode = "now", shuffle = false, startIndex = 0) {
336
+ this.command("enqueue", target, {
337
+ tracks: tracks.map((t) => ({
338
+ uploadId: t.uploadId,
339
+ trackId: t.trackId,
340
+ title: t.title,
341
+ artist: t.artist,
342
+ album: t.album,
343
+ album_artist: t.albumArtist,
344
+ album_art: t.albumArt,
345
+ duration: t.durationMs,
346
+ song_uri: t.songUri,
347
+ album_uri: t.albumUri,
348
+ track_number: t.trackNumber
349
+ })),
350
+ mode,
351
+ shuffle,
352
+ startIndex
353
+ });
354
+ }
355
+ command(action, target, args) {
356
+ const payload = { type: "command", action, token: this.getToken() };
357
+ if (target)
358
+ payload.target = target;
359
+ if (args !== undefined)
360
+ payload.args = args;
361
+ this.send(payload);
362
+ }
363
+ send(payload) {
364
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
365
+ try {
366
+ this.ws.send(JSON.stringify(payload));
367
+ } catch (e) {
368
+ this.debug("send error", e);
369
+ }
370
+ }
371
+ }
372
+ open() {
373
+ if (this.stopped)
374
+ return;
375
+ const token = this.getToken();
376
+ if (!token) {
377
+ this.reconnectTimer = setTimeout(() => this.open(), this.reconnectMs);
378
+ return;
379
+ }
380
+ let ws;
381
+ try {
382
+ ws = new WebSocket(this.url);
383
+ } catch (e) {
384
+ this.debug("connect failed", e);
385
+ this.reconnectTimer = setTimeout(() => this.open(), this.reconnectMs);
386
+ return;
387
+ }
388
+ this.ws = ws;
389
+ ws.onopen = () => {
390
+ this.debug("connected");
391
+ this.send({ type: "register", clientName: this.opts.name, token: this.getToken() });
392
+ if (this.heartbeat)
393
+ clearInterval(this.heartbeat);
394
+ this.heartbeat = setInterval(() => {
395
+ if (ws.readyState === WebSocket.OPEN)
396
+ ws.send("ping");
397
+ }, this.heartbeatMs);
398
+ };
399
+ ws.onmessage = (ev) => {
400
+ if (ev.data === "pong")
401
+ return;
402
+ let msg;
403
+ try {
404
+ msg = JSON.parse(ev.data);
405
+ } catch {
406
+ return;
407
+ }
408
+ this.handle(msg);
409
+ };
410
+ ws.onerror = () => {
411
+ try {
412
+ ws.close();
413
+ } catch {}
414
+ };
415
+ ws.onclose = () => {
416
+ this.debug("disconnected");
417
+ if (this.heartbeat)
418
+ clearInterval(this.heartbeat);
419
+ if (this.ws === ws)
420
+ this.ws = null;
421
+ if (!this.stopped) {
422
+ if (this.reconnectTimer)
423
+ clearTimeout(this.reconnectTimer);
424
+ this.reconnectTimer = setTimeout(() => this.open(), this.reconnectMs);
425
+ }
426
+ };
427
+ }
428
+ handle(msg) {
429
+ if (msg.status === "registered")
430
+ return;
431
+ switch (msg.type) {
432
+ case "devices":
433
+ this.handlers.devices?.({
434
+ primaryDevice: msg.primary_device ?? null,
435
+ devices: (msg.devices ?? []).map(deviceFromJson)
436
+ });
437
+ break;
438
+ case "device_registered":
439
+ this.handlers.deviceRegistered?.({
440
+ deviceId: msg.deviceId ?? "",
441
+ name: msg.clientName ?? ""
442
+ });
443
+ break;
444
+ case "device_unregistered":
445
+ this.handlers.deviceUnregistered?.({ deviceId: msg.device_id ?? "" });
446
+ break;
447
+ case "primary_changed":
448
+ this.handlers.primaryChanged?.({ deviceId: msg.device_id ?? "" });
449
+ break;
450
+ case "message":
451
+ this.handleMessage(msg);
452
+ break;
453
+ default:
454
+ this.debug("unknown frame", msg.type);
455
+ }
456
+ }
457
+ handleMessage(msg) {
458
+ const deviceId = msg.device_id ?? "";
459
+ const deviceName = msg.device_name ?? "";
460
+ const data = msg.data;
461
+ if (!data)
462
+ return;
463
+ switch (data.type) {
464
+ case "track":
465
+ this.handlers.nowPlaying?.({ deviceId, deviceName, track: trackFromJson(data) });
466
+ break;
467
+ case "status":
468
+ this.handlers.status?.({ deviceId, deviceName, status: statusFromCode(data.status) });
469
+ break;
470
+ case "queue":
471
+ this.handlers.queue?.({
472
+ deviceId,
473
+ deviceName,
474
+ index: data.index ?? 0,
475
+ queue: (data.queue ?? []).map(queueItemFromJson)
476
+ });
477
+ break;
478
+ }
479
+ }
480
+ }
481
+ function statusFromCode(code) {
482
+ return code === 1 ? "playing" : code === 0 ? "stopped" : "paused";
483
+ }
484
+ function trackFromJson(d) {
485
+ return {
486
+ title: d.title ?? "",
487
+ artist: d.artist ?? "",
488
+ album: d.album,
489
+ albumArtist: d.album_artist,
490
+ albumArt: d.album_art,
491
+ durationMs: d.duration_ms ?? d.length,
492
+ elapsedMs: d.elapsed,
493
+ isPlaying: d.is_playing,
494
+ songUri: d.song_uri,
495
+ albumUri: d.album_uri,
496
+ artistUri: d.artist_uri,
497
+ sha256: d.sha256,
498
+ liked: d.liked
499
+ };
500
+ }
501
+ function queueItemFromJson(d) {
502
+ return {
503
+ uploadId: d.uploadId,
504
+ trackId: d.trackId,
505
+ title: d.title ?? "",
506
+ artist: d.artist ?? "",
507
+ album: d.album,
508
+ albumArtist: d.album_artist,
509
+ albumArt: d.album_art,
510
+ durationMs: d.duration,
511
+ songUri: d.song_uri,
512
+ albumUri: d.album_uri,
513
+ trackNumber: d.track_number
514
+ };
515
+ }
516
+ function deviceFromJson(d) {
517
+ return {
518
+ deviceId: d.device_id ?? "",
519
+ name: d.name ?? "",
520
+ nowPlaying: d.now_playing ? trackFromJson(d.now_playing) : undefined,
521
+ queueIndex: d.queue?.index ?? 0,
522
+ queue: (d.queue?.queue ?? []).map(queueItemFromJson)
523
+ };
524
+ }
525
+ export {
526
+ RemotePlayer,
527
+ RemoteController,
528
+ DEFAULT_REMOTE_WS
529
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rocksky/sdk",
3
- "version": "0.10.1",
3
+ "version": "0.11.0",
4
4
  "description": "TypeScript SDK for Rocksky — built on atcute: AppView reads, AT Protocol PDS writes (scrobble, like, follow, shout), a local dedup index, and Jetstream real-time sync.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -21,6 +21,11 @@
21
21
  "types": "./dist/index.d.ts",
22
22
  "import": "./dist/index.js",
23
23
  "default": "./dist/index.js"
24
+ },
25
+ "./remote": {
26
+ "types": "./dist/remote.d.ts",
27
+ "import": "./dist/remote.js",
28
+ "default": "./dist/remote.js"
24
29
  }
25
30
  },
26
31
  "files": [
@@ -29,7 +34,7 @@
29
34
  "README.md"
30
35
  ],
31
36
  "scripts": {
32
- "build": "bun build ./src/index.ts --outdir ./dist --target node --format esm --packages external && bun run build:types",
37
+ "build": "bun build ./src/index.ts ./src/remote.ts --outdir ./dist --target node --format esm --packages external && bun run build:types",
33
38
  "build:types": "tsc -p tsconfig.build.json",
34
39
  "typecheck": "tsc --noEmit",
35
40
  "test": "bun test",
@@ -0,0 +1,164 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { Agent, MAX_SAFE_WRITES_PER_HOUR, DEFAULT_MATCH_SONG_PER_HOUR } from "./agent.js";
4
+
5
+ /**
6
+ * A fake XRPC client that counts writes and records the wall-clock time of each
7
+ * one, without ever touching a PDS. Nothing here talks to the network.
8
+ */
9
+ function fakeAgent(pds: string): {
10
+ agent: Agent;
11
+ writeTimes: number[];
12
+ } {
13
+ const writeTimes: number[] = [];
14
+ let n = 0;
15
+ const rpc = {
16
+ async post(nsid: string, opts: { input: { collection: string } }) {
17
+ if (
18
+ nsid === "com.atproto.repo.createRecord" ||
19
+ nsid === "com.atproto.repo.putRecord" ||
20
+ nsid === "com.atproto.repo.deleteRecord"
21
+ ) {
22
+ writeTimes.push(Date.now());
23
+ return { ok: true, data: { uri: `at://did:plc:test/${opts.input.collection}/rec${++n}` } };
24
+ }
25
+ return { ok: false, data: { error: "UnexpectedCall", message: nsid } };
26
+ },
27
+ };
28
+ // The constructor is private (compile-time only); bun runs the source
29
+ // directly, so we can instantiate with a stub client + no real session.
30
+ const agent = new (Agent as unknown as new (...a: unknown[]) => Agent)(rpc, "did:plc:test", {}, pds);
31
+ return { agent, writeTimes };
32
+ }
33
+
34
+ const OFFICIAL = "https://amanita.us-east.host.bsky.network";
35
+ const SELFHOSTED = "https://pds.example.com";
36
+
37
+ describe("Agent PDS identity", () => {
38
+ test("recognizes the official Bluesky PDS by *.bsky.network host", () => {
39
+ expect(fakeAgent(OFFICIAL).agent.isOfficialBlueskyPds).toBe(true);
40
+ expect(fakeAgent("https://bsky.network").agent.isOfficialBlueskyPds).toBe(true);
41
+ expect(fakeAgent("https://Puffball.US-West.HOST.BSKY.NETWORK").agent.isOfficialBlueskyPds).toBe(true);
42
+ });
43
+
44
+ test("a self-hosted PDS is not treated as official", () => {
45
+ expect(fakeAgent(SELFHOSTED).agent.isOfficialBlueskyPds).toBe(false);
46
+ // A look-alike host that only *contains* the string must not match.
47
+ expect(fakeAgent("https://not-bsky.network.evil.com").agent.isOfficialBlueskyPds).toBe(false);
48
+ expect(fakeAgent("https://bsky.network.evil.com").agent.isOfficialBlueskyPds).toBe(false);
49
+ });
50
+
51
+ test("exposes the resolved PDS host", () => {
52
+ expect(fakeAgent(SELFHOSTED).agent.pdsHost).toBe("pds.example.com");
53
+ });
54
+ });
55
+
56
+ describe("configureRateLimit policy (guard is authoritative)", () => {
57
+ test("self-hosted: disabling turns the throttle fully off", () => {
58
+ const { agent } = fakeAgent(SELFHOSTED);
59
+ const state = agent.configureRateLimit({ disabled: true });
60
+ expect(state.enabled).toBe(false);
61
+ expect(state.forcedOn).toBe(false);
62
+ expect(state.writesPerHour).toBe(Infinity);
63
+ });
64
+
65
+ test("official Bluesky: disabling is IGNORED — throttle forced back on at the safe rate", () => {
66
+ const { agent } = fakeAgent(OFFICIAL);
67
+ const state = agent.configureRateLimit({ disabled: true });
68
+ expect(state.enabled).toBe(true);
69
+ expect(state.forcedOn).toBe(true);
70
+ expect(state.writesPerHour).toBe(MAX_SAFE_WRITES_PER_HOUR);
71
+ });
72
+
73
+ test("official Bluesky: a huge writesPerHour is clamped to the safe ceiling", () => {
74
+ const { agent } = fakeAgent(OFFICIAL);
75
+ const state = agent.configureRateLimit({ writesPerHour: 1_000_000 });
76
+ expect(state.enabled).toBe(true);
77
+ expect(state.capped).toBe(true);
78
+ expect(state.writesPerHour).toBe(MAX_SAFE_WRITES_PER_HOUR);
79
+ });
80
+
81
+ test("official Bluesky: even disabled+huge can never exceed the points budget", () => {
82
+ const { agent } = fakeAgent(OFFICIAL);
83
+ const state = agent.configureRateLimit({ disabled: true, writesPerHour: 1_000_000 });
84
+ expect(state.writesPerHour).toBe(MAX_SAFE_WRITES_PER_HOUR);
85
+ // Sanity: the effective rate stays inside Bluesky's ~5000 points/hour budget.
86
+ expect(state.writesPerHour * 3).toBeLessThanOrEqual(5000);
87
+ });
88
+
89
+ test("self-hosted: a custom writesPerHour above the Bluesky ceiling is honored, not clamped", () => {
90
+ const { agent } = fakeAgent(SELFHOSTED);
91
+ const state = agent.configureRateLimit({ writesPerHour: 1_000_000 });
92
+ expect(state.enabled).toBe(true);
93
+ expect(state.capped).toBe(false);
94
+ expect(state.writesPerHour).toBe(1_000_000);
95
+ });
96
+
97
+ test("default (no options) enables the throttle at the safe rate on any PDS", () => {
98
+ expect(fakeAgent(SELFHOSTED).agent.configureRateLimit().writesPerHour).toBe(MAX_SAFE_WRITES_PER_HOUR);
99
+ expect(fakeAgent(OFFICIAL).agent.configureRateLimit().writesPerHour).toBe(MAX_SAFE_WRITES_PER_HOUR);
100
+ });
101
+
102
+ test("a zero / NaN writesPerHour falls back to the safe rate, never 'unlimited'", () => {
103
+ const { agent } = fakeAgent(SELFHOSTED);
104
+ expect(agent.configureRateLimit({ writesPerHour: 0 }).writesPerHour).toBe(MAX_SAFE_WRITES_PER_HOUR);
105
+ expect(agent.configureRateLimit({ writesPerHour: NaN }).writesPerHour).toBe(MAX_SAFE_WRITES_PER_HOUR);
106
+ expect(agent.configureRateLimit({ writesPerHour: -5 }).writesPerHour).toBe(MAX_SAFE_WRITES_PER_HOUR);
107
+ });
108
+ });
109
+
110
+ describe("matchSong AppView throttle is ALWAYS enforced", () => {
111
+ test("default matchSong rate is derived from the AppView's per-IP budget", () => {
112
+ // 1000 req / 30s * 0.9 * 3600 = 108,000/h — and never exceeds the raw budget.
113
+ expect(DEFAULT_MATCH_SONG_PER_HOUR).toBe(108_000);
114
+ expect(DEFAULT_MATCH_SONG_PER_HOUR / 3600).toBeLessThanOrEqual((1000 / 30));
115
+ expect(fakeAgent(SELFHOSTED).agent.configureRateLimit().matchSongPerHour).toBe(DEFAULT_MATCH_SONG_PER_HOUR);
116
+ });
117
+
118
+ test("disabling the write throttle does NOT disable matchSong (self-hosted)", () => {
119
+ const { agent } = fakeAgent(SELFHOSTED);
120
+ const state = agent.configureRateLimit({ disabled: true });
121
+ expect(state.enabled).toBe(false); // writes off
122
+ expect(state.matchSongPerHour).toBe(DEFAULT_MATCH_SONG_PER_HOUR); // matchSong still on
123
+ });
124
+
125
+ test("matchSongPerHour can be retuned and persists across calls", () => {
126
+ const { agent } = fakeAgent(SELFHOSTED);
127
+ expect(agent.configureRateLimit({ matchSongPerHour: 500 }).matchSongPerHour).toBe(500);
128
+ // A later call that doesn't mention matchSong keeps the tuned value.
129
+ expect(agent.configureRateLimit({ disabled: true }).matchSongPerHour).toBe(500);
130
+ // Non-positive values are ignored (never turns matchSong off).
131
+ expect(agent.configureRateLimit({ matchSongPerHour: 0 }).matchSongPerHour).toBe(500);
132
+ expect(agent.configureRateLimit({ matchSongPerHour: -1 }).matchSongPerHour).toBe(500);
133
+ });
134
+ });
135
+
136
+ describe("throttle behavior (observable, no real PDS)", () => {
137
+ test("by default there is no throttle — a burst of writes runs back-to-back", async () => {
138
+ const { agent, writeTimes } = fakeAgent(SELFHOSTED);
139
+ for (let i = 0; i < 5; i++) await agent.createArtist({ name: `Artist ${i}` });
140
+ expect(writeTimes).toHaveLength(5);
141
+ // No configured limit → the whole burst completes near-instantly.
142
+ expect(writeTimes.at(-1)! - writeTimes[0]!).toBeLessThan(50);
143
+ });
144
+
145
+ test("when enabled, writes are spaced at least the configured interval apart", async () => {
146
+ const { agent, writeTimes } = fakeAgent(SELFHOSTED);
147
+ // 180k writes/hour → 20ms minimum spacing; keeps the test fast but measurable.
148
+ agent.configureRateLimit({ writesPerHour: 180_000 });
149
+ const N = 4;
150
+ for (let i = 0; i < N; i++) await agent.createArtist({ name: `Artist ${i}` });
151
+ expect(writeTimes).toHaveLength(N);
152
+ // (N-1) gaps of ~20ms each; allow slack for timer jitter but require real spacing.
153
+ const elapsed = writeTimes.at(-1)! - writeTimes[0]!;
154
+ expect(elapsed).toBeGreaterThanOrEqual((N - 1) * 20 * 0.8);
155
+ });
156
+
157
+ test("disabling after enabling removes the spacing again (self-hosted)", async () => {
158
+ const { agent, writeTimes } = fakeAgent(SELFHOSTED);
159
+ agent.configureRateLimit({ writesPerHour: 180_000 });
160
+ agent.configureRateLimit({ disabled: true });
161
+ for (let i = 0; i < 5; i++) await agent.createArtist({ name: `Artist ${i}` });
162
+ expect(writeTimes.at(-1)! - writeTimes[0]!).toBeLessThan(50);
163
+ });
164
+ });
package/src/agent.ts CHANGED
@@ -50,6 +50,120 @@ function nowISO(): string {
50
50
  return new Date().toISOString();
51
51
  }
52
52
 
53
+ /** Lower-case hostname of a URL, or "" if it can't be parsed. */
54
+ function hostOf(url: string): string {
55
+ try {
56
+ return new URL(url).hostname.toLowerCase();
57
+ } catch {
58
+ return "";
59
+ }
60
+ }
61
+
62
+ // --- PDS write-rate limiting -----------------------------------------------
63
+ // Bluesky's PDS rate-limits repo writes by *points*, not requests: ~5,000
64
+ // points/hour per account, and each createRecord/putRecord/deleteRecord costs
65
+ // ~3 points. A bulk operation (importing a listening history) would blow that
66
+ // budget in seconds, so the Agent can throttle its own writes to stay inside
67
+ // it. The gate is OFF by default — single live scrobbles never need it — and
68
+ // callers opt in via {@link Agent.configureRateLimit}.
69
+ const PDS_WRITE_POINT_BUDGET_PER_HOUR = 5_000;
70
+ const POINTS_PER_WRITE = 3;
71
+ const SAFETY_MARGIN = 0.9; // headroom for 429 retries / clock skew
72
+
73
+ /**
74
+ * The highest sustained writes-per-hour that still fits Bluesky's write-point
75
+ * budget. On the official Bluesky PDS this is a hard ceiling the Agent will not
76
+ * let any caller exceed; self-hosted PDSes may allow more (or none).
77
+ */
78
+ export const MAX_SAFE_WRITES_PER_HOUR = Math.floor(
79
+ (PDS_WRITE_POINT_BUDGET_PER_HOUR * SAFETY_MARGIN) / POINTS_PER_WRITE,
80
+ );
81
+
82
+ // Rocksky's AppView `matchSong` endpoint is a *shared* service, and unlike PDS
83
+ // writes its rate limit is NOT the account owner's to waive — running your own
84
+ // PDS grants no extra AppView capacity. So matchSong is ALWAYS throttled,
85
+ // independent of the write throttle and of any `disabled` request.
86
+ //
87
+ // The AppView applies a global per-IP XRPC limit (see apps/api/src/index.ts:
88
+ // 1000 requests / 30s). matchSong runs against exactly that budget, so we throttle
89
+ // it to a safe fraction of the real server limit. Tune via `matchSongPerHour` if
90
+ // you operate your own AppView with a different limit.
91
+ const APPVIEW_REQUEST_LIMIT = 1_000; // requests …
92
+ const APPVIEW_WINDOW_SECONDS = 30; // … per this window (apps/api global rate limiter)
93
+ export const DEFAULT_MATCH_SONG_PER_HOUR = Math.floor(
94
+ ((APPVIEW_REQUEST_LIMIT * SAFETY_MARGIN) / APPVIEW_WINDOW_SECONDS) * 3_600,
95
+ ); // ≈ 108,000/h (~30 req/s) — 90% of the AppView's per-IP budget
96
+
97
+ /** Options for {@link Agent.configureRateLimit}. */
98
+ export interface RateLimitOptions {
99
+ /**
100
+ * Target writes (createRecord/putRecord/deleteRecord) per hour. Omitted →
101
+ * {@link MAX_SAFE_WRITES_PER_HOUR}. On the official Bluesky PDS this is
102
+ * clamped to the safe ceiling; on a self-hosted PDS it is honored as given.
103
+ * Ignored when `disabled` is true.
104
+ */
105
+ writesPerHour?: number;
106
+ /**
107
+ * Turn the client-side *write* throttle off entirely. Honored on self-hosted
108
+ * PDSes (useful when you run your own PDS with its own limits). IGNORED —
109
+ * forced back on at the safe rate — when the account lives on the official
110
+ * Bluesky PDS (*.bsky.network), whose budget is enforced server-side.
111
+ *
112
+ * NOTE: this never affects the Rocksky AppView `matchSong` throttle, which is
113
+ * always enforced (see {@link RateLimitOptions.matchSongPerHour}).
114
+ */
115
+ disabled?: boolean;
116
+ /**
117
+ * Target Rocksky AppView `matchSong` calls per hour. ALWAYS enforced — a
118
+ * self-hosted PDS grants no extra AppView capacity — so `disabled` never turns
119
+ * it off; this only tunes the rate. Omitted → keep the current value
120
+ * (default {@link DEFAULT_MATCH_SONG_PER_HOUR}). Non-positive values are ignored.
121
+ */
122
+ matchSongPerHour?: number;
123
+ }
124
+
125
+ /** The effective throttle state after {@link Agent.configureRateLimit} applies policy. */
126
+ export interface RateLimitState {
127
+ /** Whether the *write* throttle is active. */
128
+ enabled: boolean;
129
+ /** Effective writes/hour cap (Infinity when disabled). */
130
+ writesPerHour: number;
131
+ /** True when `disabled` was requested but overridden by the bsky.network guard. */
132
+ forcedOn: boolean;
133
+ /** True when a requested `writesPerHour` was clamped to the safe ceiling. */
134
+ capped: boolean;
135
+ /** The resolved PDS host the decision was based on. */
136
+ pdsHost: string;
137
+ /** Effective Rocksky AppView matchSong rate — always enforced, never disabled. */
138
+ matchSongPerHour: number;
139
+ }
140
+
141
+ /**
142
+ * Global throttle: spaces calls at least `minIntervalMs` apart. A single shared
143
+ * `nextAt` cursor is advanced atomically per {@link RateGate.take}, so even
144
+ * highly concurrent callers never burst past the configured rate.
145
+ * `minIntervalMs <= 0` means no throttle (take() resolves immediately).
146
+ */
147
+ class RateGate {
148
+ private nextAt = 0;
149
+ private minIntervalMs = 0;
150
+
151
+ /** null / non-positive → no throttle. */
152
+ setRate(writesPerHour: number | null): void {
153
+ this.minIntervalMs =
154
+ writesPerHour && writesPerHour > 0 ? Math.ceil(3_600_000 / writesPerHour) : 0;
155
+ }
156
+
157
+ async take(): Promise<void> {
158
+ if (this.minIntervalMs <= 0) return;
159
+ const now = Date.now();
160
+ const at = Math.max(now, this.nextAt);
161
+ this.nextAt = at + this.minIntervalMs;
162
+ const delay = at - now;
163
+ if (delay > 0) await new Promise((r) => setTimeout(r, delay));
164
+ }
165
+ }
166
+
53
167
  // Write inputs: `createdAt` is optional (the SDK defaults it to now).
54
168
  /** Input for {@link Agent.scrobble} (createdAt defaults to now). */
55
169
  export type ScrobbleInput = Omit<ScrobbleRecord, "createdAt"> & { createdAt?: string };
@@ -80,13 +194,88 @@ export type ArtistInput = Omit<ArtistRecord, "createdAt"> & { createdAt?: string
80
194
  */
81
195
  export class Agent {
82
196
  private idx?: RockskyIndex;
197
+ // PDS write throttle — off by default: single live scrobbles don't need it. An
198
+ // import (or any bulk writer) opts in via configureRateLimit().
199
+ private writeGate = new RateGate();
200
+ // Rocksky AppView matchSong throttle — ALWAYS on. A self-hosted PDS grants no
201
+ // extra AppView capacity, so this is never disabled, only tuned.
202
+ private matchGate = new RateGate();
203
+ private matchSongPerHour = DEFAULT_MATCH_SONG_PER_HOUR;
204
+
205
+ /** Lower-case hostname of the account's resolved PDS (e.g. "pds.example.com"). */
206
+ readonly pdsHost: string;
83
207
 
84
208
  private constructor(
85
209
  private rpc: Client,
86
210
  readonly did: string,
87
211
  readonly session: PasswordSession,
88
212
  private pds: string,
89
- ) {}
213
+ ) {
214
+ this.pdsHost = hostOf(pds);
215
+ this.matchGate.setRate(this.matchSongPerHour);
216
+ }
217
+
218
+ /**
219
+ * Whether the account lives on the official Bluesky PDS (*.bsky.network).
220
+ * Its write budget is enforced server-side, so the client-side throttle can
221
+ * never be disabled for these hosts.
222
+ */
223
+ get isOfficialBlueskyPds(): boolean {
224
+ return this.pdsHost === "bsky.network" || this.pdsHost.endsWith(".bsky.network");
225
+ }
226
+
227
+ /**
228
+ * Configure the client-side write throttle and return the effective state.
229
+ *
230
+ * Policy — the *.bsky.network guard is authoritative and cannot be bypassed:
231
+ * - `disabled: true` turns the throttle off on a self-hosted PDS, but on the
232
+ * official Bluesky PDS it is ignored and the throttle stays on at the safe
233
+ * rate (`forcedOn: true`).
234
+ * - `writesPerHour` is honored as given on a self-hosted PDS, but clamped to
235
+ * {@link MAX_SAFE_WRITES_PER_HOUR} on the official Bluesky PDS
236
+ * (`capped: true` when clamped).
237
+ * - Omitting both enables the throttle at the safe default rate.
238
+ */
239
+ configureRateLimit(opts: RateLimitOptions = {}): RateLimitState {
240
+ // matchSong throttle is always enforced — `disabled` never touches it. A
241
+ // positive `matchSongPerHour` retunes it; anything else keeps the current rate.
242
+ if (opts.matchSongPerHour !== undefined && opts.matchSongPerHour > 0) {
243
+ this.matchSongPerHour = opts.matchSongPerHour;
244
+ this.matchGate.setRate(this.matchSongPerHour);
245
+ }
246
+
247
+ const official = this.isOfficialBlueskyPds;
248
+ const state = (partial: Omit<RateLimitState, "pdsHost" | "matchSongPerHour">): RateLimitState => ({
249
+ ...partial,
250
+ pdsHost: this.pdsHost,
251
+ matchSongPerHour: this.matchSongPerHour,
252
+ });
253
+
254
+ if (opts.disabled) {
255
+ if (official) {
256
+ // Guard: never let the write throttle be turned off on the official
257
+ // Bluesky PDS — that only earns 429s and risks account-level throttling.
258
+ const writesPerHour = Math.min(
259
+ opts.writesPerHour ?? MAX_SAFE_WRITES_PER_HOUR,
260
+ MAX_SAFE_WRITES_PER_HOUR,
261
+ );
262
+ this.writeGate.setRate(writesPerHour);
263
+ return state({ enabled: true, writesPerHour, forcedOn: true, capped: false });
264
+ }
265
+ this.writeGate.setRate(null);
266
+ return state({ enabled: false, writesPerHour: Infinity, forcedOn: false, capped: false });
267
+ }
268
+
269
+ let writesPerHour = opts.writesPerHour ?? MAX_SAFE_WRITES_PER_HOUR;
270
+ if (!(writesPerHour > 0)) writesPerHour = MAX_SAFE_WRITES_PER_HOUR; // reject 0 / NaN / negatives
271
+ let capped = false;
272
+ if (official && writesPerHour > MAX_SAFE_WRITES_PER_HOUR) {
273
+ writesPerHour = MAX_SAFE_WRITES_PER_HOUR;
274
+ capped = true;
275
+ }
276
+ this.writeGate.setRate(writesPerHour);
277
+ return state({ enabled: true, writesPerHour, forcedOn: false, capped });
278
+ }
90
279
 
91
280
  /**
92
281
  * Resolve the account's PDS, authenticate with an app password, and return an
@@ -132,6 +321,7 @@ export class Agent {
132
321
  }
133
322
 
134
323
  private async create(collection: string, record: Record<string, unknown>): Promise<string> {
324
+ await this.writeGate.take();
135
325
  const res = await this.rpc.post("com.atproto.repo.createRecord" as never, {
136
326
  input: { repo: this.did, collection, record: { ...record, $type: collection } },
137
327
  } as never);
@@ -140,6 +330,7 @@ export class Agent {
140
330
  }
141
331
 
142
332
  private async putRecord(collection: string, rkey: string, record: Record<string, unknown>): Promise<string> {
333
+ await this.writeGate.take();
143
334
  const res = await this.rpc.post("com.atproto.repo.putRecord" as never, {
144
335
  input: { repo: this.did, collection, rkey, record: { ...record, $type: collection } },
145
336
  } as never);
@@ -149,6 +340,7 @@ export class Agent {
149
340
 
150
341
  /** Delete a record by collection + rkey. */
151
342
  async delete(collection: string, rkey: string): Promise<void> {
343
+ await this.writeGate.take();
152
344
  const res = await this.rpc.post("com.atproto.repo.deleteRecord" as never, {
153
345
  input: { repo: this.did, collection, rkey },
154
346
  } as never);
@@ -223,6 +415,9 @@ export class Agent {
223
415
  async scrobbleMatch(input: ScrobbleMatchInput, appview?: string): Promise<string> {
224
416
  const { title, artist, album, mbId, isrc, timestamp } = input;
225
417
  const { RockskyClient } = await import("./client.js");
418
+ // matchSong hits the shared Rocksky AppView; always throttle it, regardless
419
+ // of the write-throttle policy (a self-hosted PDS grants no AppView capacity).
420
+ await this.matchGate.take();
226
421
  const m = (await new RockskyClient(appview).matchSong(title, artist, mbId, isrc)) as Record<
227
422
  string,
228
423
  unknown
package/src/index.ts CHANGED
@@ -13,11 +13,15 @@ export type { DateInterval } from "./client.js";
13
13
  export { RockskyLibrary } from "./library.js";
14
14
  export {
15
15
  Agent,
16
+ MAX_SAFE_WRITES_PER_HOUR,
17
+ DEFAULT_MATCH_SONG_PER_HOUR,
16
18
  type ScrobbleInput,
17
19
  type ScrobbleMatchInput,
18
20
  type SongInput,
19
21
  type AlbumInput,
20
22
  type ArtistInput,
23
+ type RateLimitOptions,
24
+ type RateLimitState,
21
25
  } from "./agent.js";
22
26
  export { RockskyIndex, totalIndexed, type IndexStats } from "./dedup.js";
23
27
  export { runJetstream, DEFAULT_JETSTREAM_SERVERS, type JetstreamOptions } from "./jetstream.js";
package/src/remote.ts ADDED
@@ -0,0 +1,23 @@
1
+ // Browser-safe entry: the remote-control player + controller only.
2
+ //
3
+ // The main entry (`@rocksky/sdk`) bundles the dedup index (classic-level) and
4
+ // the identity hashes (node:crypto), which are Node-only. The remote player /
5
+ // controller are pure WebSocket + JSON with zero Node dependencies, so this
6
+ // subpath (`@rocksky/sdk/remote`) is safe to import from a browser bundle.
7
+
8
+ export {
9
+ RemotePlayer,
10
+ DEFAULT_REMOTE_WS,
11
+ type RemotePlayerOptions,
12
+ type RemotePlayerHandlers,
13
+ type RemoteNowPlaying,
14
+ type RemoteQueueItem,
15
+ type EnqueueCommand,
16
+ } from "./remote-player.js";
17
+ export {
18
+ RemoteController,
19
+ type RemoteControllerOptions,
20
+ type RemoteControllerEvents,
21
+ type RemoteDevice,
22
+ type RemoteStatus,
23
+ } from "./remote-controller.js";