@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/CHANGELOG.md +10 -0
- package/LICENSE.txt +7 -0
- package/README.md +221 -0
- package/dist/channel.d.ts +32 -0
- package/dist/channel.d.ts.map +1 -0
- package/dist/channel.js +146 -0
- package/dist/channel.js.map +1 -0
- package/dist/client.d.ts +19 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +104 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -0
- package/dist/simple-indexer.d.ts +17 -0
- package/dist/simple-indexer.d.ts.map +1 -0
- package/dist/simple-indexer.js +53 -0
- package/dist/simple-indexer.js.map +1 -0
- package/dist/types.d.ts +286 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +75 -0
- package/dist/types.js.map +1 -0
- package/dist/util.d.ts +4 -0
- package/dist/util.d.ts.map +1 -0
- package/dist/util.js +37 -0
- package/dist/util.js.map +1 -0
- package/jest.config.js +10 -0
- package/package.json +43 -0
- package/src/channel.ts +152 -0
- package/src/client.ts +100 -0
- package/src/index.ts +5 -0
- package/src/simple-indexer.ts +47 -0
- package/src/types.ts +109 -0
- package/src/util.ts +33 -0
- package/tests/channel.test.ts +379 -0
- package/tests/client.test.ts +208 -0
- package/tests/simple-indexer.test.ts +188 -0
- package/tests/util.test.ts +88 -0
- package/tsconfig.build.json +9 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +4 -0
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,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
|
+
}
|