@atproto/tap 0.0.2

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/src/channel.ts ADDED
@@ -0,0 +1,152 @@
1
+ import { ClientOptions } from 'ws'
2
+ import { Deferrable, createDeferrable, isErrnoException } from '@atproto/common'
3
+ import { WebSocketKeepAlive } from '@atproto/ws-client'
4
+ import { TapEvent, parseTapEvent } from './types'
5
+ import { formatAdminAuthHeader } from './util'
6
+
7
+ export interface HandlerOpts {
8
+ signal: AbortSignal
9
+ ack: () => Promise<void>
10
+ }
11
+
12
+ export interface TapHandler {
13
+ onEvent: (evt: TapEvent, opts: HandlerOpts) => void | Promise<void>
14
+ onError: (err: Error) => void
15
+ }
16
+
17
+ export type TapWebsocketOptions = ClientOptions & {
18
+ adminPassword?: string
19
+ maxReconnectSeconds?: number
20
+ heartbeatIntervalMs?: number
21
+ onReconnectError?: (error: unknown, n: number, initialSetup: boolean) => void
22
+ }
23
+
24
+ type BufferedAck = {
25
+ id: number
26
+ defer: Deferrable
27
+ }
28
+
29
+ export class TapChannel {
30
+ private ws: WebSocketKeepAlive
31
+ private handler: TapHandler
32
+
33
+ private readonly abortController: AbortController = new AbortController()
34
+ private readonly destroyDefer: Deferrable = createDeferrable()
35
+
36
+ private bufferedAcks: BufferedAck[] = []
37
+
38
+ constructor(
39
+ url: string,
40
+ handler: TapHandler,
41
+ wsOpts: TapWebsocketOptions = {},
42
+ ) {
43
+ this.handler = handler
44
+ const { adminPassword, ...rest } = wsOpts
45
+ let headers = rest.headers
46
+ if (adminPassword) {
47
+ headers ??= {}
48
+ headers['Authorization'] = formatAdminAuthHeader(adminPassword)
49
+ }
50
+ this.ws = new WebSocketKeepAlive({
51
+ getUrl: async () => url,
52
+ onReconnect: () => {
53
+ this.flushBufferedAcks()
54
+ },
55
+ signal: this.abortController.signal,
56
+ ...rest,
57
+ headers,
58
+ })
59
+ }
60
+
61
+ async ackEvent(id: number): Promise<void> {
62
+ if (this.ws.isConnected()) {
63
+ try {
64
+ await this.sendAck(id)
65
+ } catch {
66
+ await this.bufferAndSendAck(id)
67
+ }
68
+ } else {
69
+ await this.bufferAndSendAck(id)
70
+ }
71
+ }
72
+
73
+ private async sendAck(id: number): Promise<void> {
74
+ await this.ws.send(JSON.stringify({ type: 'ack', id }))
75
+ }
76
+
77
+ // resolves after the ack has been actually sent
78
+ private async bufferAndSendAck(id: number): Promise<void> {
79
+ const defer = createDeferrable()
80
+ this.bufferedAcks.push({
81
+ id,
82
+ defer,
83
+ })
84
+ await defer.complete
85
+ }
86
+
87
+ private async flushBufferedAcks(): Promise<void> {
88
+ while (this.bufferedAcks.length > 0) {
89
+ try {
90
+ const ack = this.bufferedAcks.at(0)
91
+ if (!ack) {
92
+ return
93
+ }
94
+ await this.sendAck(ack.id)
95
+ ack.defer.resolve()
96
+ this.bufferedAcks = this.bufferedAcks.slice(1)
97
+ } catch (err) {
98
+ this.handler.onError(
99
+ new Error(`failed to send ack for event ${this.bufferedAcks[0]}`, {
100
+ cause: err,
101
+ }),
102
+ )
103
+ return
104
+ }
105
+ }
106
+ }
107
+
108
+ async start() {
109
+ try {
110
+ for await (const chunk of this.ws) {
111
+ await this.processWsEvent(chunk)
112
+ }
113
+ } catch (err) {
114
+ if (isErrnoException(err) && err.name === 'AbortError') {
115
+ this.destroyDefer.resolve()
116
+ } else {
117
+ throw err
118
+ }
119
+ }
120
+ }
121
+
122
+ private async processWsEvent(chunk: Uint8Array) {
123
+ let evt: TapEvent
124
+ try {
125
+ const data = chunk.toString()
126
+ evt = parseTapEvent(JSON.parse(data))
127
+ } catch (err) {
128
+ this.handler.onError(new Error('Failed to parse message', { cause: err }))
129
+ return
130
+ }
131
+
132
+ try {
133
+ await this.handler.onEvent(evt, {
134
+ signal: this.abortController.signal,
135
+ ack: async () => {
136
+ await this.ackEvent(evt.id)
137
+ },
138
+ })
139
+ } catch (err) {
140
+ // Don't ack on error - let Tap retry
141
+ this.handler.onError(
142
+ new Error(`Failed to process event ${evt.id}`, { cause: err }),
143
+ )
144
+ return
145
+ }
146
+ }
147
+
148
+ async destroy(): Promise<void> {
149
+ this.abortController.abort()
150
+ await this.destroyDefer.complete
151
+ }
152
+ }
package/src/client.ts ADDED
@@ -0,0 +1,100 @@
1
+ import { DidDocument, didDocument } from '@atproto/common'
2
+ import { TapChannel, TapHandler, TapWebsocketOptions } from './channel'
3
+ import { RepoInfo, repoInfoSchema } from './types'
4
+ import { formatAdminAuthHeader } from './util'
5
+
6
+ export interface TapConfig {
7
+ adminPassword?: string
8
+ }
9
+
10
+ export class Tap {
11
+ url: string
12
+ private adminPassword?: string
13
+ private authHeader?: string
14
+
15
+ constructor(url: string, config: TapConfig = {}) {
16
+ if (!url.startsWith('http://') && !url.startsWith('https://')) {
17
+ throw new Error('Invalid URL, expected http:// or https://')
18
+ }
19
+ this.url = url
20
+ this.adminPassword = config.adminPassword
21
+ if (this.adminPassword) {
22
+ this.authHeader = formatAdminAuthHeader(this.adminPassword)
23
+ }
24
+ }
25
+
26
+ private getHeaders(): Record<string, string> {
27
+ const headers: Record<string, string> = {
28
+ 'Content-Type': 'application/json',
29
+ }
30
+ if (this.authHeader) {
31
+ headers['Authorization'] = this.authHeader
32
+ }
33
+ return headers
34
+ }
35
+
36
+ channel(handler: TapHandler, opts?: TapWebsocketOptions): TapChannel {
37
+ const url = new URL(this.url)
38
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
39
+ url.pathname = '/channel'
40
+ return new TapChannel(url.toString(), handler, {
41
+ adminPassword: this.adminPassword,
42
+ ...opts,
43
+ })
44
+ }
45
+
46
+ async addRepos(dids: string[]): Promise<void> {
47
+ const response = await fetch(`${this.url}/repos/add`, {
48
+ method: 'POST',
49
+ headers: this.getHeaders(),
50
+ body: JSON.stringify({ dids }),
51
+ })
52
+ await response.body?.cancel() // expect empty body
53
+
54
+ if (!response.ok) {
55
+ throw new Error(`Failed to add repos: ${response.statusText}`)
56
+ }
57
+ }
58
+
59
+ async removeRepos(dids: string[]): Promise<void> {
60
+ const response = await fetch(`${this.url}/repos/remove`, {
61
+ method: 'POST',
62
+ headers: this.getHeaders(),
63
+ body: JSON.stringify({ dids }),
64
+ })
65
+ await response.body?.cancel() // expect empty body
66
+
67
+ if (!response.ok) {
68
+ throw new Error(`Failed to remove repos: ${response.statusText}`)
69
+ }
70
+ }
71
+
72
+ async resolveDid(did: string): Promise<DidDocument | null> {
73
+ const response = await fetch(`${this.url}/resolve/${did}`, {
74
+ method: 'GET',
75
+ headers: this.getHeaders(),
76
+ })
77
+
78
+ if (response.status === 404) {
79
+ return null
80
+ } else if (!response.ok) {
81
+ await response.body?.cancel()
82
+ throw new Error(`Failed to resolve DID: ${response.statusText}`)
83
+ }
84
+ return didDocument.parse(await response.json())
85
+ }
86
+
87
+ async getRepoInfo(did: string): Promise<RepoInfo> {
88
+ const response = await fetch(`${this.url}/info/${did}`, {
89
+ method: 'GET',
90
+ headers: this.getHeaders(),
91
+ })
92
+
93
+ if (!response.ok) {
94
+ await response.body?.cancel()
95
+ throw new Error(`Failed to get repo info: ${response.statusText}`)
96
+ }
97
+
98
+ return repoInfoSchema.parse(await response.json())
99
+ }
100
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from './types'
2
+ export * from './client'
3
+ export * from './channel'
4
+ export * from './simple-indexer'
5
+ export * from './util'
@@ -0,0 +1,47 @@
1
+ import { HandlerOpts, TapHandler } from './channel'
2
+ import { IdentityEvent, RecordEvent, TapEvent } from './types'
3
+
4
+ type IdentityEventHandler = (
5
+ evt: IdentityEvent,
6
+ opts?: HandlerOpts,
7
+ ) => Promise<void>
8
+ type RecordEventHandler = (
9
+ evt: RecordEvent,
10
+ opts?: HandlerOpts,
11
+ ) => Promise<void>
12
+ type ErrorHandler = (err: Error) => void
13
+
14
+ export class SimpleIndexer implements TapHandler {
15
+ private identityHandler: IdentityEventHandler | undefined
16
+ private recordHandler: RecordEventHandler | undefined
17
+ private errorHandler: ErrorHandler | undefined
18
+
19
+ identity(fn: IdentityEventHandler) {
20
+ this.identityHandler = fn
21
+ }
22
+
23
+ record(fn: RecordEventHandler) {
24
+ this.recordHandler = fn
25
+ }
26
+
27
+ error(fn: ErrorHandler) {
28
+ this.errorHandler = fn
29
+ }
30
+
31
+ async onEvent(evt: TapEvent, opts: HandlerOpts): Promise<void> {
32
+ if (evt.type === 'record') {
33
+ await this.recordHandler?.(evt, opts)
34
+ } else {
35
+ await this.identityHandler?.(evt, opts)
36
+ }
37
+ await opts.ack()
38
+ }
39
+
40
+ onError(err: Error) {
41
+ if (this.errorHandler) {
42
+ this.errorHandler(err)
43
+ } else {
44
+ throw err
45
+ }
46
+ }
47
+ }
package/src/types.ts ADDED
@@ -0,0 +1,109 @@
1
+ import { z } from 'zod'
2
+
3
+ export const recordEventDataSchema = z.object({
4
+ did: z.string(),
5
+ rev: z.string(),
6
+ collection: z.string(),
7
+ rkey: z.string(),
8
+ action: z.enum(['create', 'update', 'delete']),
9
+ record: z.record(z.string(), z.unknown()).optional(),
10
+ cid: z.string().optional(),
11
+ live: z.boolean(),
12
+ })
13
+
14
+ export const identityEventDataSchema = z.object({
15
+ did: z.string(),
16
+ handle: z.string(),
17
+ is_active: z.boolean(),
18
+ status: z.enum([
19
+ 'active',
20
+ 'takendown',
21
+ 'suspended',
22
+ 'deactivated',
23
+ 'deleted',
24
+ ]),
25
+ })
26
+
27
+ export const recordEventSchema = z.object({
28
+ id: z.number(),
29
+ type: z.literal('record'),
30
+ record: recordEventDataSchema,
31
+ })
32
+
33
+ export const identityEventSchema = z.object({
34
+ id: z.number(),
35
+ type: z.literal('identity'),
36
+ identity: identityEventDataSchema,
37
+ })
38
+
39
+ export const tapEventSchema = z.union([recordEventSchema, identityEventSchema])
40
+
41
+ export type RecordEvent = {
42
+ id: number
43
+ type: 'record'
44
+ action: 'create' | 'update' | 'delete'
45
+ did: string
46
+ rev: string
47
+ collection: string
48
+ rkey: string
49
+ record?: Record<string, unknown>
50
+ cid?: string
51
+ live: boolean
52
+ }
53
+
54
+ export type IdentityEvent = {
55
+ id: number
56
+ type: 'identity'
57
+ did: string
58
+ handle: string
59
+ isActive: boolean
60
+ status: RepoStatus
61
+ }
62
+
63
+ export type RepoStatus =
64
+ | 'active'
65
+ | 'takendown'
66
+ | 'suspended'
67
+ | 'deactivated'
68
+ | 'deleted'
69
+
70
+ export type TapEvent = IdentityEvent | RecordEvent
71
+
72
+ export const parseTapEvent = (data: unknown): TapEvent => {
73
+ const parsed = tapEventSchema.parse(data)
74
+ if (parsed.type === 'identity') {
75
+ return {
76
+ id: parsed.id,
77
+ type: parsed.type,
78
+ did: parsed.identity.did,
79
+ handle: parsed.identity.handle,
80
+ isActive: parsed.identity.is_active,
81
+ status: parsed.identity.status,
82
+ }
83
+ } else {
84
+ return {
85
+ id: parsed.id,
86
+ type: parsed.type,
87
+ action: parsed.record.action,
88
+ did: parsed.record.did,
89
+ rev: parsed.record.rev,
90
+ collection: parsed.record.collection,
91
+ rkey: parsed.record.rkey,
92
+ record: parsed.record.record,
93
+ cid: parsed.record.cid,
94
+ live: parsed.record.live,
95
+ }
96
+ }
97
+ }
98
+
99
+ export const repoInfoSchema = z.object({
100
+ did: z.string(),
101
+ handle: z.string(),
102
+ state: z.string(),
103
+ rev: z.string(),
104
+ records: z.number(),
105
+ error: z.string().optional(),
106
+ retries: z.number().optional(),
107
+ })
108
+
109
+ export type RepoInfo = z.infer<typeof repoInfoSchema>
package/src/util.ts ADDED
@@ -0,0 +1,33 @@
1
+ export const formatAdminAuthHeader = (password: string) => {
2
+ return 'Basic ' + Buffer.from(`admin:${password}`).toString('base64')
3
+ }
4
+
5
+ export const parseAdminAuthHeader = (header: string) => {
6
+ const noPrefix = header.startsWith('Basic ') ? header.slice(6) : header
7
+ const [username, password] = Buffer.from(noPrefix, 'base64')
8
+ .toString()
9
+ .split(':')
10
+ if (username !== 'admin') {
11
+ throw new Error("Unexpected username in admin headers. Expected 'admin'")
12
+ }
13
+ return password
14
+ }
15
+
16
+ export const assureAdminAuth = (expectedPassword: string, header: string) => {
17
+ const headerPassword = parseAdminAuthHeader(header)
18
+ const passEqual = timingSafeEqual(headerPassword, expectedPassword)
19
+ if (!passEqual) {
20
+ throw new Error('Invalid admin password')
21
+ }
22
+ }
23
+
24
+ const timingSafeEqual = (a: string, b: string): boolean => {
25
+ const bufA = Buffer.from(a)
26
+ const bufB = Buffer.from(b)
27
+ if (bufA.length !== bufB.length) {
28
+ // Compare against self to maintain constant time even with length mismatch
29
+ Buffer.from(a).compare(Buffer.from(a))
30
+ return false
31
+ }
32
+ return bufA.compare(bufB) === 0
33
+ }