1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
// XRPC subscriptions over WebSocket for hosts:
// the browser WebSocket or Node 22+'s global one, no deps. atproto event streams send binary
// frames, a DAG-CBOR header { op, t } then a DAG-CBOR body; jetstream-style streams send JSON text.
import { dagCbor, toBase32 } from "../encoding/index.js";
// fromCharCode in chunks: spreading a large byte string overflows the stack
const toBase64 = (bytes) => {
let text = "";
for (let i = 0; i < bytes.length; i += 0x8000) text += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
return btoa(text).replace(/=+$/, "");
};
const fromBase64 = (text) => Uint8Array.from(atob(text.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0));
/** Arrays, maps and tags nest at most this deep: frames come from the network, and each level recurses. */
const MAX_DEPTH = 128;
/**
* Decode consecutive DAG-CBOR items into the atproto JSON data model: bytes become { $bytes: base64 },
* CIDs (tag 42) { $link: base32 CIDv1 }. Returns the items.
* @param {Uint8Array} bytes
*/
export function decodeCbor(bytes) {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
let at = 0;
const length = (info) => {
if (info < 24) return info;
if (info === 24) return view.getUint8(at++);
if (info === 25) return (at += 2), view.getUint16(at - 2);
if (info === 26) return (at += 4), view.getUint32(at - 4);
if (info === 27) return (at += 8), Number(view.getBigUint64(at - 8));
throw new Error("CBOR: indefinite lengths are not DAG-CBOR");
};
const span = (n) => {
if (at + n > bytes.length) throw new Error("CBOR: truncated");
return bytes.subarray(at, (at += n));
};
const item = (depth = 0) => {
if (depth > MAX_DEPTH) throw new Error(`CBOR: nested deeper than ${MAX_DEPTH}`);
const next = () => item(depth + 1);
const head = view.getUint8(at++);
const major = head >> 5;
const info = head & 31;
if (major === 7) {
if (info === 20) return false;
if (info === 21) return true;
if (info === 22 || info === 23) return null;
if (info === 25) return (at += 2), view.getFloat16?.(at - 2) ?? NaN;
if (info === 26) return (at += 4), view.getFloat32(at - 4);
if (info === 27) return (at += 8), view.getFloat64(at - 8);
throw new Error(`CBOR: simple value ${info}`);
}
const n = length(info);
if (major === 0) return n;
if (major === 1) return -1 - n;
if (major === 2) return { $bytes: toBase64(span(n)) };
if (major === 3) return new TextDecoder().decode(span(n));
if (major === 4) return Array.from({ length: n }, next);
if (major === 5) return Object.fromEntries(Array.from({ length: n }, () => [next(), next()]));
// major 6: tag 42 is a CID, bytes with a 0x00 multibase-identity prefix.
const tagged = next();
if (n !== 42 || !tagged.$bytes) throw new Error(`CBOR: tag ${n}`);
return { $link: `b${toBase32(fromBase64(tagged.$bytes).subarray(1))}` };
};
const items = [];
while (at < bytes.length) items.push(item());
return items;
}
/** Encode values (the atproto JSON data model, as decodeCbor returns it) as consecutive DAG-CBOR items. */
export function encodeCbor(...values) {
const items = values.map((value) => dagCbor(value, { links: true }));
const out = new Uint8Array(items.reduce((n, item) => n + item.length, 0));
items.reduce((at, item) => (out.set(item, at), at + item.length), 0);
return out;
}
/**
* One event-stream frame as a message: { $type: nsid + t, ...body } for op 1; an error frame (op -1)
* throws its error. Text is JSON.
*/
export function decodeFrame(data, nsid = "") {
if (typeof data === "string") return JSON.parse(data);
const [header, body] = decodeCbor(new Uint8Array(data));
if (header?.op === -1) throw new Error(`${body?.error ?? "error frame"}${body?.message ? `: ${body.message}` : ""}`);
return { $type: `${nsid}${header?.t ?? ""}`, ...body };
}
/**
* What send(data) writes: a string as a text frame; { $bytes } as a binary frame; with framing "cbor"
* an object { $type: "...#t", ...body } as an event-stream frame (header { op: 1, t }); otherwise JSON text.
*/
export function encodeFrame(data, framing) {
if (typeof data === "string") return data;
if (data && typeof data.$bytes === "string" && Object.keys(data).length === 1) return fromBase64(data.$bytes);
if (framing === "cbor") {
const { $type = "", ...body } = data ?? {};
// t is the "#fragment" of $type; with no "#" the header has no t ($type is the nsid itself)
const hash = String($type).indexOf("#");
return encodeCbor(hash < 0 ? { op: 1 } : { op: 1, t: String($type).slice(hash) }, body);
}
return JSON.stringify(data);
}
/**
* Resolve a subscribe request's serviceAuth: mint(service, nsid)
* resolves to a service-auth token (aud the service's did:web, lxm nsid). { param } puts it in that query
* parameter; {} sends it as Authorization, which only hosts whose WebSocket takes headers can do
* (canHeader false in browsers). Returns the request without serviceAuth, with params or headers set.
*/
export async function withServiceAuth(nsid, request, mint, { canHeader = true } = {}) {
const { serviceAuth, ...rest } = request;
if (rest.headers && !canHeader) throw new Error(`${nsid}: browsers cannot set WebSocket headers`);
if (!serviceAuth) return rest;
if (!rest.service?.startsWith("https://")) throw new Error(`${nsid} serviceAuth needs an https service`);
if (serviceAuth.param === undefined && !canHeader) throw new Error(`${nsid}: browsers cannot set WebSocket headers; serviceAuth needs { param }, for a service that takes the token in a query parameter`);
const token = await mint(rest.service, nsid);
if (typeof token !== "string" || !token) throw new Error(`${nsid}: no service-auth token`);
return serviceAuth.param === undefined ? { ...rest, headers: { ...rest.headers, authorization: `Bearer ${token}` } } : { ...rest, params: { ...rest.params, [serviceAuth.param]: token } };
}
export const wsUrl = (base, nsid, params) => {
const url = new URL(`/xrpc/${nsid}`, base);
url.protocol = url.protocol === "http:" ? "ws:" : "wss:";
if (params) url.search = new URLSearchParams(params).toString();
return url.href;
};
const HIGH_WATER = 1 << 20; // bytes buffered before send() waits for the socket to drain
/**
* Open a WebSocket subscription. The result is an async iterable of decoded messages with
* send(data) (see encodeFrame; resolves once the frame is buffered under HIGH_WATER) and close().
* The iterable ends when the socket closes cleanly and throws when it fails or an error frame arrives.
* @param {string} url wss://host/xrpc/nsid?params (wsUrl)
* @param {{ nsid?: string, signal?: AbortSignal, framing?: "cbor" | "json", headers?: Record<string, string>,
* keepalive?: { ms: number, data: unknown }, WebSocket?: typeof WebSocket }} options
* framing only changes what send() writes; incoming binary is always CBOR frames and text JSON.
* headers: Node's WebSocket only (browsers cannot set them). keepalive: send data every ms.
*/
export function subscribeXrpc(url, { nsid, signal, framing, headers, keepalive, WebSocket: WS = globalThis.WebSocket } = {}) {
const ws = headers ? new WS(url, { headers }) : new WS(url);
ws.binaryType = "arraybuffer";
const queue = [];
let finished = false;
let failure;
let wake;
const notify = () => wake?.();
const opened = new Promise((resolve, reject) => {
ws.onopen = resolve;
ws.onclose = (event) => {
finished = true;
if (!failure && event.code !== 1000 && event.code !== 1005) failure = new Error(`${nsid ?? url} closed: ${event.code}${event.reason ? ` ${event.reason}` : ""}`);
clearInterval(ping);
reject(failure ?? new Error(`${nsid ?? url} closed`));
notify();
};
});
opened.catch(() => {});
ws.onerror = () => (failure ??= new Error(`${nsid ?? url}: WebSocket error`));
ws.onmessage = ({ data }) => {
try {
queue.push(decodeFrame(data, nsid));
} catch (error) {
failure = error;
ws.close();
}
notify();
};
const close = () => ws.readyState < 2 && ws.close(1000);
signal?.addEventListener("abort", close, { once: true });
if (signal?.aborted) close();
const send = async (data) => {
await opened;
const frame = encodeFrame(data, framing);
while (ws.bufferedAmount > HIGH_WATER && ws.readyState === 1) await new Promise((r) => setTimeout(r, 10));
if (ws.readyState !== 1) throw new Error(`${nsid ?? url} is closed`);
ws.send(frame);
};
// clamped to setTimeout's limit: past it Node fires every 1 ms
const ping = keepalive?.ms > 0 ? setInterval(() => send(keepalive.data).catch(() => {}), Math.min(keepalive.ms, 2 ** 31 - 1)) : undefined;
return {
send,
close,
[Symbol.asyncIterator]() {
return {
next: async () => {
while (!queue.length && !finished) await new Promise((resolve) => (wake = resolve));
if (queue.length) return { value: queue.shift(), done: false };
if (failure) throw failure;
return { value: undefined, done: true };
},
return: async () => (close(), { value: undefined, done: true }),
};
},
};
}