+2
README.md
+2
README.md
···
36
36
| [`client`](./packages/clients/client): XRPC HTTP client |
37
37
| [`firehose`](./packages/clients/firehose): XRPC subscription client |
38
38
| [`jetstream`](./packages/clients/jetstream): Jetstream WebSocket client |
39
+
| [`tap`](./packages/clients/tap): Tap WebSocket client |
39
40
| [`cache`](./packages/clients/cache): normalized cache store |
40
41
| **Server packages** |
41
42
| [`xrpc-server`](./packages/servers/xrpc-server): XRPC web framework |
···
45
46
| [`xrpc-server-node`](./packages/servers/xrpc-server-node): Node.js WebSocket adapter |
46
47
| **OAuth packages** |
47
48
| [`oauth-browser-client`](./packages/oauth/browser-client): minimal OAuth client for SPAs |
49
+
| [`oauth-node-client`](./packages/oauth/node-client): OAuth client for Node.js |
48
50
| **Lexicon packages** |
49
51
| [`lex-cli`](./packages/lexicons/lex-cli): generate TypeScript from lexicon schemas |
50
52
| [`lexicon-doc`](./packages/lexicons/lexicon-doc): parse and author lexicon documents |
+33
packages/clients/tap/README.md
+33
packages/clients/tap/README.md
···
1
+
# @atcute/tap
2
+
3
+
an atproto Tap client.
4
+
5
+
```sh
6
+
npm install @atcute/tap
7
+
```
8
+
9
+
Tap is a sync utility that connects to the relay firehose, verifies data, performs backfill, and
10
+
streams simple JSON events to clients over a local websocket.
11
+
12
+
## usage
13
+
14
+
```ts
15
+
import { TapClient } from '@atcute/tap';
16
+
17
+
const tap = new TapClient({
18
+
url: 'http://localhost:2480',
19
+
// adminPassword: process.env.TAP_ADMIN_PASSWORD,
20
+
});
21
+
22
+
await tap.addRepos(['did:plc:...']);
23
+
24
+
for await (const { event, ack } of tap.subscribe()) {
25
+
// process event idempotently
26
+
await ack();
27
+
}
28
+
```
29
+
30
+
## acks
31
+
32
+
Tap’s default mode requires that clients acknowledge each event after it has been processed. if you
33
+
don’t call `ack()`, Tap will retry delivery later.
+24
packages/clients/tap/lib/dev-index.ts
+24
packages/clients/tap/lib/dev-index.ts
···
1
+
import { TapClient } from './tap-client.js';
2
+
3
+
const tap = new TapClient({ url: 'http://localhost:2480' });
4
+
5
+
const subscription = tap.subscribe({
6
+
onConnectionOpen() {
7
+
console.log(`ws open`);
8
+
},
9
+
onConnectionClose() {
10
+
console.log(`ws close`);
11
+
},
12
+
onConnectionError(ev) {
13
+
console.log(`ws error`, ev);
14
+
},
15
+
onError(err) {
16
+
console.error('tap subscription error', err);
17
+
},
18
+
});
19
+
20
+
for await (const { event, ack } of subscription) {
21
+
console.log(event);
22
+
23
+
await ack();
24
+
}
+5
packages/clients/tap/lib/index.ts
+5
packages/clients/tap/lib/index.ts
+114
packages/clients/tap/lib/tap-client.ts
+114
packages/clients/tap/lib/tap-client.ts
···
1
+
import { defs as identityDefs, type DidDocument } from '@atcute/identity';
2
+
3
+
import type { Did } from '@atcute/lexicons';
4
+
5
+
import { TapSubscription } from './tap-subscription.js';
6
+
import { repoInfoSchema } from './typedefs.js';
7
+
import type { RepoInfo, TapClientOptions, TapSubscribeOptions } from './types.js';
8
+
import { formatAdminAuthHeader } from './utils.js';
9
+
10
+
export class TapClient {
11
+
#url: URL;
12
+
#fetch: typeof globalThis.fetch;
13
+
#adminPassword?: string;
14
+
#authHeader?: string;
15
+
16
+
constructor(options: TapClientOptions) {
17
+
const url = typeof options.url === 'string' ? new URL(options.url) : new URL(options.url);
18
+
19
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
20
+
throw new Error(`invalid url protocol, expected http: or https:, got ${url.protocol}`);
21
+
}
22
+
23
+
this.#url = url;
24
+
this.#fetch = options.fetch ?? fetch;
25
+
26
+
if (options.adminPassword) {
27
+
this.#adminPassword = options.adminPassword;
28
+
this.#authHeader = formatAdminAuthHeader(options.adminPassword);
29
+
}
30
+
}
31
+
32
+
subscribe(options?: TapSubscribeOptions): TapSubscription {
33
+
const wsUrl = new URL(this.#url);
34
+
wsUrl.protocol = wsUrl.protocol === 'https:' ? 'wss:' : 'ws:';
35
+
wsUrl.pathname = '/channel';
36
+
37
+
return new TapSubscription({
38
+
url: wsUrl.toString(),
39
+
adminPassword: this.#adminPassword,
40
+
...options,
41
+
});
42
+
}
43
+
44
+
async addRepos(dids: Did[]): Promise<void> {
45
+
const response = await this.#fetch(new URL('/repos/add', this.#url), {
46
+
method: 'POST',
47
+
headers: this.#getHeaders(),
48
+
body: JSON.stringify({ dids }),
49
+
});
50
+
51
+
await response.body?.cancel();
52
+
if (!response.ok) {
53
+
throw new Error(`failed to add repos: ${response.status} ${response.statusText}`);
54
+
}
55
+
}
56
+
57
+
async removeRepos(dids: Did[]): Promise<void> {
58
+
const response = await this.#fetch(new URL('/repos/remove', this.#url), {
59
+
method: 'POST',
60
+
headers: this.#getHeaders(),
61
+
body: JSON.stringify({ dids }),
62
+
});
63
+
64
+
await response.body?.cancel();
65
+
if (!response.ok) {
66
+
throw new Error(`failed to remove repos: ${response.status} ${response.statusText}`);
67
+
}
68
+
}
69
+
70
+
async resolveDid(did: Did): Promise<DidDocument | null> {
71
+
const response = await this.#fetch(new URL(`/resolve/${did}`, this.#url), {
72
+
method: 'GET',
73
+
headers: this.#getHeaders(),
74
+
});
75
+
76
+
if (response.status === 404) {
77
+
await response.body?.cancel();
78
+
return null;
79
+
}
80
+
81
+
if (!response.ok) {
82
+
await response.body?.cancel();
83
+
throw new Error(`failed to resolve did: ${response.status} ${response.statusText}`);
84
+
}
85
+
86
+
return identityDefs.didDocument.parse(await response.json());
87
+
}
88
+
89
+
async getRepoInfo(did: Did): Promise<RepoInfo> {
90
+
const response = await this.#fetch(new URL(`/info/${did}`, this.#url), {
91
+
method: 'GET',
92
+
headers: this.#getHeaders(),
93
+
});
94
+
95
+
if (!response.ok) {
96
+
await response.body?.cancel();
97
+
throw new Error(`failed to get repo info: ${response.status} ${response.statusText}`);
98
+
}
99
+
100
+
return repoInfoSchema.parse(await response.json());
101
+
}
102
+
103
+
#getHeaders(): Record<string, string> {
104
+
const headers: Record<string, string> = {
105
+
'Content-Type': 'application/json',
106
+
};
107
+
108
+
if (this.#authHeader) {
109
+
headers['Authorization'] = this.#authHeader;
110
+
}
111
+
112
+
return headers;
113
+
}
114
+
}
+95
packages/clients/tap/lib/tap-subscription.test.ts
+95
packages/clients/tap/lib/tap-subscription.test.ts
···
1
+
import { describe, expect, it } from 'vitest';
2
+
import { WebSocketServer, type RawData, type WebSocket } from 'ws';
3
+
4
+
import * as v from '@badrap/valita';
5
+
import { TapSubscription } from './tap-subscription.js';
6
+
import { tapRecordEventWireSchema } from './typedefs.js';
7
+
8
+
import { decodeUtf8From } from '@atcute/uint8array';
9
+
10
+
type RecordEventWire = v.Infer<typeof tapRecordEventWireSchema>;
11
+
12
+
const createRecordEvent = (id: number): RecordEventWire => ({
13
+
id,
14
+
type: 'record',
15
+
record: {
16
+
did: 'did:plc:ewvi7nxzyoun6zhxrhs64oiz',
17
+
rev: '3k3m5z2zq2f2x',
18
+
collection: 'app.bsky.feed.post',
19
+
rkey: '3k3m5z2zq2f2x',
20
+
action: 'create',
21
+
record: { text: 'hello', $type: 'app.bsky.feed.post' },
22
+
cid: 'bafyreigkq6j3o7v2xq2xq2xq2xq2xq2xq2xq2xq2xq2xq2xq2xq2x',
23
+
live: true,
24
+
},
25
+
});
26
+
27
+
describe('tap subscription', () => {
28
+
it('receives events and sends acks', async () => {
29
+
const server = new WebSocketServer({ port: 0 });
30
+
const address = server.address();
31
+
if (typeof address === 'string' || address === null) {
32
+
throw new Error(`unexpected ws address`);
33
+
}
34
+
35
+
const receivedAcks: number[] = [];
36
+
37
+
server.on('connection', (socket: WebSocket) => {
38
+
socket.send(JSON.stringify(createRecordEvent(42)));
39
+
socket.on('message', (data: RawData) => {
40
+
const msg = JSON.parse(typeof data === 'string' ? data : decodeUtf8From(data as Uint8Array));
41
+
if (msg.type === 'ack') {
42
+
receivedAcks.push(msg.id);
43
+
socket.close();
44
+
}
45
+
});
46
+
});
47
+
48
+
const subscription = new TapSubscription({
49
+
url: `ws://127.0.0.1:${address.port}/channel`,
50
+
});
51
+
52
+
const iterator = subscription[Symbol.asyncIterator]();
53
+
const next = await iterator.next();
54
+
if (next.done) {
55
+
throw new Error(`expected message`);
56
+
}
57
+
58
+
expect(next.value.event.type).toBe('record');
59
+
await next.value.ack();
60
+
61
+
await iterator.return?.();
62
+
63
+
await new Promise<void>((resolve) => server.close(() => resolve()));
64
+
expect(receivedAcks).toEqual([42]);
65
+
});
66
+
67
+
it('drops malformed messages', async () => {
68
+
const server = new WebSocketServer({ port: 0 });
69
+
const address = server.address();
70
+
if (typeof address === 'string' || address === null) {
71
+
throw new Error(`unexpected ws address`);
72
+
}
73
+
74
+
let errors = 0;
75
+
76
+
server.on('connection', (socket: WebSocket) => {
77
+
socket.send('not json');
78
+
setTimeout(() => socket.close(), 25);
79
+
});
80
+
81
+
const subscription = new TapSubscription({
82
+
url: `ws://127.0.0.1:${address.port}/channel`,
83
+
onError: () => {
84
+
errors++;
85
+
},
86
+
});
87
+
88
+
const iterator = subscription[Symbol.asyncIterator]();
89
+
await new Promise((resolve) => setTimeout(resolve, 75));
90
+
await iterator.return?.();
91
+
92
+
await new Promise<void>((resolve) => server.close(() => resolve()));
93
+
expect(errors).toBeGreaterThan(0);
94
+
});
95
+
});
+267
packages/clients/tap/lib/tap-subscription.ts
+267
packages/clients/tap/lib/tap-subscription.ts
···
1
+
import { EventIterator } from '@mary-ext/event-iterator';
2
+
import { SimpleEventEmitter } from '@mary-ext/simple-event-emitter';
3
+
import { WebSocket as ReconnectingWebSocket } from 'partysocket';
4
+
5
+
import type { ReadonlyDeep } from 'type-fest';
6
+
7
+
import { decodeUtf8From } from '@atcute/uint8array';
8
+
9
+
import { flattenTapEvent, tapEventWireSchema } from './typedefs.js';
10
+
import type { TapEvent, TapSubscribeOptions, TapSubscriptionMessage } from './types.js';
11
+
import { formatAdminAuthHeader } from './utils.js';
12
+
13
+
export interface TapSubscriptionOptions extends TapSubscribeOptions {
14
+
url: string;
15
+
adminPassword?: string;
16
+
}
17
+
18
+
type BufferedAck = {
19
+
id: number;
20
+
promise: Promise<void>;
21
+
resolve: (value: void) => void;
22
+
reject: (reason?: unknown) => void;
23
+
};
24
+
25
+
const PARSE_OPTIONS = { mode: 'passthrough' } as const;
26
+
27
+
export class TapSubscription {
28
+
#listening = 0;
29
+
#ws?: ReconnectingWebSocket;
30
+
31
+
#emitter = new SimpleEventEmitter<[message: TapSubscriptionMessage]>();
32
+
#bufferedAcks: BufferedAck[] = [];
33
+
34
+
#options: TapSubscriptionOptions;
35
+
#closed = false;
36
+
37
+
constructor(options: TapSubscriptionOptions) {
38
+
this.#options = options;
39
+
}
40
+
41
+
#sendAck(id: number): boolean {
42
+
const ws = this.#ws;
43
+
if (ws === undefined) {
44
+
return false;
45
+
}
46
+
47
+
if (ws.readyState !== 1) {
48
+
return false;
49
+
}
50
+
51
+
ws.send(JSON.stringify({ type: 'ack', id }));
52
+
return true;
53
+
}
54
+
55
+
async #ackEvent(id: number): Promise<void> {
56
+
if (this.#closed) {
57
+
throw new Error(`tap subscription is closed`);
58
+
}
59
+
60
+
try {
61
+
if (this.#sendAck(id)) {
62
+
return;
63
+
}
64
+
} catch {
65
+
// fall through to buffering
66
+
}
67
+
68
+
const { promise, resolve, reject } = Promise.withResolvers<void>();
69
+
this.#bufferedAcks.push({ id, promise, resolve, reject });
70
+
return await promise;
71
+
}
72
+
73
+
#flushBufferedAcks() {
74
+
while (this.#bufferedAcks.length > 0) {
75
+
const ack = this.#bufferedAcks[0];
76
+
if (ack === undefined) {
77
+
return;
78
+
}
79
+
80
+
try {
81
+
if (!this.#sendAck(ack.id)) {
82
+
return;
83
+
}
84
+
85
+
ack.resolve(undefined);
86
+
this.#bufferedAcks = this.#bufferedAcks.slice(1);
87
+
} catch (err) {
88
+
this.#options.onError?.(err);
89
+
return;
90
+
}
91
+
}
92
+
}
93
+
94
+
#create() {
95
+
if (this.#ws !== undefined) {
96
+
return;
97
+
}
98
+
99
+
const {
100
+
url,
101
+
adminPassword,
102
+
ws: wsOptions,
103
+
validateEvents = true,
104
+
onConnectionClose,
105
+
onConnectionError,
106
+
onConnectionOpen,
107
+
onError,
108
+
} = this.#options;
109
+
110
+
const emitter = this.#emitter;
111
+
112
+
const authHeader = adminPassword ? formatAdminAuthHeader(adminPassword) : undefined;
113
+
114
+
const mergedWsOptions =
115
+
authHeader !== undefined && wsOptions?.WebSocket === undefined
116
+
? {
117
+
...wsOptions,
118
+
WebSocket: createAuthedWebSocket(authHeader),
119
+
}
120
+
: wsOptions;
121
+
122
+
const ws = new ReconnectingWebSocket(() => url, null, mergedWsOptions);
123
+
this.#ws = ws;
124
+
125
+
ws.binaryType = 'arraybuffer';
126
+
127
+
ws.onclose = onConnectionClose ?? null;
128
+
ws.onerror = onConnectionError ?? null;
129
+
130
+
ws.onopen = (ev) => {
131
+
this.#flushBufferedAcks();
132
+
onConnectionOpen?.(ev);
133
+
};
134
+
135
+
ws.onmessage = (ev) => {
136
+
let raw: unknown;
137
+
try {
138
+
const data = toMessageText(ev.data);
139
+
raw = JSON.parse(data);
140
+
} catch (err) {
141
+
onError?.(new Error(`failed to parse tap message`, { cause: err }));
142
+
return;
143
+
}
144
+
145
+
let evt: TapEvent;
146
+
if (validateEvents) {
147
+
const result = tapEventWireSchema.try(raw, PARSE_OPTIONS);
148
+
if (!result.ok) {
149
+
onError?.(result);
150
+
return;
151
+
}
152
+
153
+
evt = flattenTapEvent(result.value);
154
+
} else {
155
+
try {
156
+
evt = flattenTapEvent(raw as any);
157
+
} catch (err) {
158
+
onError?.(err);
159
+
return;
160
+
}
161
+
}
162
+
163
+
let acked = false;
164
+
let ackPromise: Promise<void> | undefined;
165
+
166
+
emitter.emit({
167
+
event: evt,
168
+
ack: () => {
169
+
if (!acked) {
170
+
acked = true;
171
+
ackPromise = this.#ackEvent(evt.id);
172
+
}
173
+
return ackPromise!;
174
+
},
175
+
});
176
+
};
177
+
}
178
+
179
+
#destroy() {
180
+
const ws = this.#ws;
181
+
if (ws) {
182
+
ws.close();
183
+
this.#ws = undefined;
184
+
}
185
+
186
+
this.#closed = true;
187
+
188
+
if (this.#bufferedAcks.length > 0) {
189
+
const err = new Error(`tap subscription closed before ack was sent`);
190
+
for (const ack of this.#bufferedAcks) {
191
+
ack.reject(err);
192
+
}
193
+
this.#bufferedAcks = [];
194
+
}
195
+
}
196
+
197
+
[Symbol.asyncIterator]() {
198
+
return new EventIterator<TapSubscriptionMessage>((emit) => {
199
+
if (this.#listening === 0) {
200
+
this.#closed = false;
201
+
this.#create();
202
+
}
203
+
204
+
this.#listening++;
205
+
this.#emitter.subscribe(emit);
206
+
207
+
return () => {
208
+
if (this.#listening === 1) {
209
+
this.#destroy();
210
+
}
211
+
212
+
this.#listening--;
213
+
this.#emitter.unsubscribe(emit);
214
+
};
215
+
});
216
+
}
217
+
218
+
getOptions(): ReadonlyDeep<TapSubscriptionOptions> {
219
+
return this.#options;
220
+
}
221
+
222
+
updateOptions(options: Partial<TapSubscriptionOptions>): void {
223
+
this.#options = { ...this.#options, ...options };
224
+
225
+
if (this.#ws !== undefined) {
226
+
this.#destroy();
227
+
this.#closed = false;
228
+
this.#create();
229
+
}
230
+
}
231
+
}
232
+
233
+
const toMessageText = (data: unknown): string => {
234
+
if (typeof data === 'string') {
235
+
return data;
236
+
}
237
+
238
+
if (data instanceof ArrayBuffer) {
239
+
return decodeUtf8From(new Uint8Array(data));
240
+
}
241
+
242
+
if (ArrayBuffer.isView(data)) {
243
+
return decodeUtf8From(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
244
+
}
245
+
246
+
return String(data);
247
+
};
248
+
249
+
const createAuthedWebSocket = (authorization: string) => {
250
+
const WebSocketCtor = WebSocket as unknown as {
251
+
new (
252
+
url: string | URL,
253
+
protocols?: string | string[],
254
+
options?: { headers?: Record<string, string> },
255
+
): WebSocket;
256
+
};
257
+
258
+
return class AuthedWebSocket extends WebSocketCtor {
259
+
constructor(url: string | URL, protocols?: string | string[]) {
260
+
super(url, protocols as any, {
261
+
headers: {
262
+
Authorization: authorization,
263
+
},
264
+
});
265
+
}
266
+
};
267
+
};
+102
packages/clients/tap/lib/typedefs.ts
+102
packages/clients/tap/lib/typedefs.ts
···
1
+
import * as v from '@badrap/valita';
2
+
3
+
import { isDid, isHandle, isNsid, isRecordKey, isTid } from '@atcute/lexicons/syntax';
4
+
5
+
import type * as t from './types.js';
6
+
7
+
const didString = v.string().assert(isDid, `must be a did`);
8
+
const handleString = v.string().assert(isHandle, `must be a handle`);
9
+
const nsidString = v.string().assert(isNsid, `must be an nsid`);
10
+
const rkeyString = v.string().assert(isRecordKey, `must be a record key`);
11
+
const tidString = v.string().assert(isTid, `must be a tid`);
12
+
13
+
const integer = v
14
+
.number()
15
+
.assert((input) => input >= 0 && Number.isSafeInteger(input), `must be a nonnegative integer`);
16
+
17
+
const recordEventDataSchema = v.object({
18
+
did: didString,
19
+
rev: tidString,
20
+
collection: nsidString,
21
+
rkey: rkeyString,
22
+
action: v.union(v.literal('create'), v.literal('update'), v.literal('delete')),
23
+
record: v.record(v.unknown()).optional(),
24
+
cid: v.string().optional(),
25
+
live: v.boolean(),
26
+
});
27
+
28
+
const identityEventDataSchema = v.object({
29
+
did: didString,
30
+
handle: handleString,
31
+
is_active: v.boolean(),
32
+
status: v.union(
33
+
v.literal('active'),
34
+
v.literal('takendown'),
35
+
v.literal('suspended'),
36
+
v.literal('deactivated'),
37
+
v.literal('deleted'),
38
+
),
39
+
});
40
+
41
+
export const tapRecordEventWireSchema = v.object({
42
+
id: integer,
43
+
type: v.literal('record'),
44
+
record: recordEventDataSchema,
45
+
});
46
+
47
+
export const tapIdentityEventWireSchema = v.object({
48
+
id: integer,
49
+
type: v.literal('identity'),
50
+
identity: identityEventDataSchema,
51
+
});
52
+
53
+
export const tapEventWireSchema = v.union(tapRecordEventWireSchema, tapIdentityEventWireSchema);
54
+
55
+
export const repoInfoSchema: v.Type<t.RepoInfo> = v.object({
56
+
did: didString,
57
+
handle: handleString,
58
+
state: v.string(),
59
+
rev: tidString,
60
+
records: integer,
61
+
error: v.string().optional(),
62
+
retries: integer.optional(),
63
+
});
64
+
65
+
export const flattenTapEvent = (wire: v.Infer<typeof tapEventWireSchema>): t.TapEvent => {
66
+
switch (wire.type) {
67
+
case 'identity': {
68
+
return {
69
+
id: wire.id,
70
+
type: 'identity',
71
+
72
+
did: wire.identity.did,
73
+
handle: wire.identity.handle,
74
+
isActive: wire.identity.is_active,
75
+
status: wire.identity.status,
76
+
};
77
+
}
78
+
79
+
case 'record': {
80
+
return {
81
+
id: wire.id,
82
+
type: 'record',
83
+
live: wire.record.live,
84
+
85
+
rev: wire.record.rev,
86
+
did: wire.record.did,
87
+
collection: wire.record.collection,
88
+
rkey: wire.record.rkey,
89
+
cid: wire.record.cid,
90
+
action: wire.record.action,
91
+
record: wire.record.record,
92
+
};
93
+
}
94
+
95
+
default: {
96
+
wire satisfies never;
97
+
98
+
const obj = wire as any;
99
+
throw new Error(`unknown "${obj.type}" type`);
100
+
}
101
+
}
102
+
};
+68
packages/clients/tap/lib/types.ts
+68
packages/clients/tap/lib/types.ts
···
1
+
import type { Did, Handle, Nsid, RecordKey, Tid } from '@atcute/lexicons/syntax';
2
+
import type { CloseEvent, ErrorEvent, Options } from 'partysocket/ws';
3
+
4
+
export type TapRecordAction = 'create' | 'update' | 'delete';
5
+
6
+
export type TapRepoStatus = 'active' | 'takendown' | 'suspended' | 'deactivated' | 'deleted';
7
+
8
+
export interface TapRecordEvent {
9
+
id: number;
10
+
type: 'record';
11
+
12
+
live: boolean;
13
+
did: Did;
14
+
rev: Tid;
15
+
collection: Nsid;
16
+
rkey: RecordKey;
17
+
action: TapRecordAction;
18
+
record?: Record<string, unknown>;
19
+
cid?: string;
20
+
}
21
+
22
+
export interface TapIdentityEvent {
23
+
id: number;
24
+
type: 'identity';
25
+
26
+
did: Did;
27
+
handle: Handle;
28
+
isActive: boolean;
29
+
status: TapRepoStatus;
30
+
}
31
+
32
+
export type TapEvent = TapRecordEvent | TapIdentityEvent;
33
+
34
+
export interface RepoInfo {
35
+
did: Did;
36
+
handle: Handle;
37
+
state: string;
38
+
rev: Tid;
39
+
records: number;
40
+
error?: string;
41
+
retries?: number;
42
+
}
43
+
44
+
export interface TapClientOptions {
45
+
url: string | URL;
46
+
adminPassword?: string;
47
+
fetch?: typeof globalThis.fetch;
48
+
}
49
+
50
+
export interface TapSubscribeOptions {
51
+
/**
52
+
* whether to validate incoming events.
53
+
* @default true
54
+
*/
55
+
validateEvents?: boolean;
56
+
57
+
onConnectionOpen?: (event: Event) => void;
58
+
onConnectionClose?: (event: CloseEvent) => void;
59
+
onConnectionError?: (event: ErrorEvent) => void;
60
+
onError?: (error: unknown) => void;
61
+
62
+
ws?: Options;
63
+
}
64
+
65
+
export interface TapSubscriptionMessage {
66
+
event: TapEvent;
67
+
ack: () => Promise<void>;
68
+
}
+6
packages/clients/tap/lib/utils.ts
+6
packages/clients/tap/lib/utils.ts
+41
packages/clients/tap/package.json
+41
packages/clients/tap/package.json
···
1
+
{
2
+
"type": "module",
3
+
"name": "@atcute/tap",
4
+
"version": "0.1.0",
5
+
"description": "an atproto Tap client",
6
+
"license": "0BSD",
7
+
"repository": {
8
+
"url": "https://github.com/mary-ext/atcute",
9
+
"directory": "packages/clients/tap"
10
+
},
11
+
"files": [
12
+
"dist/",
13
+
"lib/",
14
+
"!lib/**/*.bench.ts",
15
+
"!lib/**/*.test.ts"
16
+
],
17
+
"exports": {
18
+
".": "./dist/index.js"
19
+
},
20
+
"scripts": {
21
+
"build": "tsgo --project tsconfig.build.json",
22
+
"test": "vitest run",
23
+
"prepublish": "rm -rf dist; pnpm run build"
24
+
},
25
+
"dependencies": {
26
+
"@atcute/multibase": "workspace:^",
27
+
"@atcute/uint8array": "workspace:^",
28
+
"@atcute/identity": "workspace:^",
29
+
"@atcute/lexicons": "workspace:^",
30
+
"@badrap/valita": "^0.4.6",
31
+
"@mary-ext/event-iterator": "^1.0.0",
32
+
"@mary-ext/simple-event-emitter": "^1.0.0",
33
+
"partysocket": "^1.1.6",
34
+
"type-fest": "^4.41.0"
35
+
},
36
+
"devDependencies": {
37
+
"@types/ws": "^8.18.1",
38
+
"vitest": "^4.0.14",
39
+
"ws": "^8.18.3"
40
+
}
41
+
}
+5
packages/clients/tap/tsconfig.build.json
+5
packages/clients/tap/tsconfig.build.json
+25
packages/clients/tap/tsconfig.json
+25
packages/clients/tap/tsconfig.json
···
1
+
{
2
+
"compilerOptions": {
3
+
"outDir": "dist/",
4
+
"esModuleInterop": true,
5
+
"skipLibCheck": true,
6
+
"target": "ESNext",
7
+
"allowJs": true,
8
+
"resolveJsonModule": true,
9
+
"moduleDetection": "force",
10
+
"isolatedModules": true,
11
+
"verbatimModuleSyntax": true,
12
+
"strict": true,
13
+
"noImplicitOverride": true,
14
+
"noUnusedLocals": true,
15
+
"noUnusedParameters": true,
16
+
"useDefineForClassFields": false,
17
+
"noFallthroughCasesInSwitch": true,
18
+
"module": "NodeNext",
19
+
"sourceMap": true,
20
+
"declaration": true,
21
+
"declarationMap": true
22
+
},
23
+
"include": ["lib"]
24
+
}
25
+
+7
packages/clients/tap/vitest.config.ts
+7
packages/clients/tap/vitest.config.ts
+85
pnpm-lock.yaml
+85
pnpm-lock.yaml
···
206
206
specifier: ^4.41.0
207
207
version: 4.41.0
208
208
209
+
packages/clients/tap:
210
+
dependencies:
211
+
'@atcute/identity':
212
+
specifier: workspace:^
213
+
version: link:../../identity/identity
214
+
'@atcute/lexicons':
215
+
specifier: workspace:^
216
+
version: link:../../lexicons/lexicons
217
+
'@atcute/multibase':
218
+
specifier: workspace:^
219
+
version: link:../../utilities/multibase
220
+
'@atcute/uint8array':
221
+
specifier: workspace:^
222
+
version: link:../../misc/uint8array
223
+
'@badrap/valita':
224
+
specifier: ^0.4.6
225
+
version: 0.4.6
226
+
'@mary-ext/event-iterator':
227
+
specifier: ^1.0.0
228
+
version: 1.0.0
229
+
'@mary-ext/simple-event-emitter':
230
+
specifier: ^1.0.0
231
+
version: 1.0.0
232
+
partysocket:
233
+
specifier: ^1.1.6
234
+
version: 1.1.6
235
+
type-fest:
236
+
specifier: ^4.41.0
237
+
version: 4.41.0
238
+
devDependencies:
239
+
'@types/ws':
240
+
specifier: ^8.18.1
241
+
version: 8.18.1
242
+
vitest:
243
+
specifier: ^4.0.14
244
+
version: 4.0.15(@types/node@24.10.1)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.0)
245
+
ws:
246
+
specifier: ^8.18.3
247
+
version: 8.18.3
248
+
209
249
packages/definitions/atproto:
210
250
dependencies:
211
251
'@atcute/lexicons':
···
6159
6199
optionalDependencies:
6160
6200
vite: 7.2.4(@types/node@22.19.1)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.0)
6161
6201
6202
+
'@vitest/mocker@4.0.15(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.0))':
6203
+
dependencies:
6204
+
'@vitest/spy': 4.0.15
6205
+
estree-walker: 3.0.3
6206
+
magic-string: 0.30.21
6207
+
optionalDependencies:
6208
+
vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.0)
6209
+
6162
6210
'@vitest/pretty-format@4.0.14':
6163
6211
dependencies:
6164
6212
tinyrainbow: 3.0.3
···
7873
7921
why-is-node-running: 2.3.0
7874
7922
optionalDependencies:
7875
7923
'@types/node': 22.19.1
7924
+
transitivePeerDependencies:
7925
+
- jiti
7926
+
- less
7927
+
- lightningcss
7928
+
- msw
7929
+
- sass
7930
+
- sass-embedded
7931
+
- stylus
7932
+
- sugarss
7933
+
- terser
7934
+
- tsx
7935
+
- yaml
7936
+
7937
+
vitest@4.0.15(@types/node@24.10.1)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.0):
7938
+
dependencies:
7939
+
'@vitest/expect': 4.0.15
7940
+
'@vitest/mocker': 4.0.15(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.0))
7941
+
'@vitest/pretty-format': 4.0.15
7942
+
'@vitest/runner': 4.0.15
7943
+
'@vitest/snapshot': 4.0.15
7944
+
'@vitest/spy': 4.0.15
7945
+
'@vitest/utils': 4.0.15
7946
+
es-module-lexer: 1.7.0
7947
+
expect-type: 1.2.2
7948
+
magic-string: 0.30.21
7949
+
obug: 2.1.1
7950
+
pathe: 2.0.3
7951
+
picomatch: 4.0.3
7952
+
std-env: 3.10.0
7953
+
tinybench: 2.9.0
7954
+
tinyexec: 1.0.2
7955
+
tinyglobby: 0.2.15
7956
+
tinyrainbow: 3.0.3
7957
+
vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.0)
7958
+
why-is-node-running: 2.3.0
7959
+
optionalDependencies:
7960
+
'@types/node': 24.10.1
7876
7961
transitivePeerDependencies:
7877
7962
- jiti
7878
7963
- less