@atcute/firehose 0.1.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/LICENSE ADDED
@@ -0,0 +1,14 @@
1
+ BSD Zero Clause License
2
+
3
+ Copyright (c) 2025 Mary
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted.
7
+
8
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
9
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
10
+ AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
11
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
12
+ LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
13
+ OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
14
+ PERFORMANCE OF THIS SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,23 @@
1
+ # @atcute/firehose
2
+
3
+ lightweight and cute XRPC subscription client for AT Protocol.
4
+
5
+ ```ts
6
+ import { FirehoseSubscription } from '@atcute/firehose';
7
+ import { ComAtprotoSyncSubscribeRepos } from '@atcute/atproto';
8
+
9
+ let cursor: number | undefined;
10
+
11
+ const subscription = new FirehoseSubscription({
12
+ service: ['wss://bsky.network'],
13
+ nsid: ComAtprotoSyncSubscribeRepos.mainSchema,
14
+ params: () => ({ cursor }),
15
+ });
16
+
17
+ for await (const message of subscription) {
18
+ if (message.$type === 'com.atproto.sync.subscribeRepos#commit') {
19
+ cursor = message.seq;
20
+ console.log('commit:', message.seq, message.repo);
21
+ }
22
+ }
23
+ ```
@@ -0,0 +1,14 @@
1
+ import type { DecodedFrame } from './types.js';
2
+ /**
3
+ * decodes a CBOR frame from a buffer
4
+ */
5
+ export declare const decodeFrame: (buffer: Uint8Array) => DecodedFrame;
6
+ /**
7
+ * reconstructs full $type field from discriminator and nsid
8
+ */
9
+ export declare const reconstructType: (discriminator: string | undefined, nsid: string) => string | undefined;
10
+ /**
11
+ * adds $type field to message body if discriminator is present
12
+ */
13
+ export declare const addTypeToBody: (body: unknown, discriminator: string | undefined, nsid: string) => unknown;
14
+ //# sourceMappingURL=frame-decoder.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"frame-decoder.d.ts","sourceRoot":"","sources":["../lib/frame-decoder.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAA+B,MAAM,YAAY,CAAC;AAE5E;;GAEG;AACH,eAAO,MAAM,WAAW,GAAI,QAAQ,UAAU,KAAG,YAuBhD,CAAC;AAeF;;GAEG;AACH,eAAO,MAAM,eAAe,GAAI,eAAe,MAAM,GAAG,SAAS,EAAE,MAAM,MAAM,KAAG,MAAM,GAAG,SAU1F,CAAC;AAEF;;GAEG;AACH,eAAO,MAAM,aAAa,GAAI,MAAM,OAAO,EAAE,eAAe,MAAM,GAAG,SAAS,EAAE,MAAM,MAAM,KAAG,OAQ9F,CAAC"}
@@ -0,0 +1,59 @@
1
+ import { decode, decodeFirst } from '@atcute/cbor';
2
+ /**
3
+ * decodes a CBOR frame from a buffer
4
+ */
5
+ export const decodeFrame = (buffer) => {
6
+ const [header, afterHeader] = decodeFirst(buffer);
7
+ if (!isValidHeader(header)) {
8
+ throw new Error('invalid frame header');
9
+ }
10
+ const body = decode(afterHeader);
11
+ if (header.op === 1) {
12
+ return {
13
+ type: 'message',
14
+ body,
15
+ discriminator: header.t,
16
+ };
17
+ }
18
+ else {
19
+ const errorBody = body;
20
+ return {
21
+ type: 'error',
22
+ error: errorBody.error,
23
+ message: errorBody.message,
24
+ };
25
+ }
26
+ };
27
+ /**
28
+ * type guard for frame header
29
+ */
30
+ const isValidHeader = (value) => {
31
+ if (value === null || typeof value !== 'object') {
32
+ return false;
33
+ }
34
+ const obj = value;
35
+ return (obj.op === 1 || obj.op === -1) && (obj.t === undefined || typeof obj.t === 'string');
36
+ };
37
+ /**
38
+ * reconstructs full $type field from discriminator and nsid
39
+ */
40
+ export const reconstructType = (discriminator, nsid) => {
41
+ if (!discriminator) {
42
+ return undefined;
43
+ }
44
+ if (discriminator[0] === '#') {
45
+ return nsid + discriminator;
46
+ }
47
+ return discriminator;
48
+ };
49
+ /**
50
+ * adds $type field to message body if discriminator is present
51
+ */
52
+ export const addTypeToBody = (body, discriminator, nsid) => {
53
+ const type = reconstructType(discriminator, nsid);
54
+ if (type && typeof body === 'object' && body !== null) {
55
+ body.$type = type;
56
+ }
57
+ return body;
58
+ };
59
+ //# sourceMappingURL=frame-decoder.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"frame-decoder.js","sourceRoot":"","sources":["../lib/frame-decoder.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAInD;;GAEG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,CAAC,MAAkB,EAAgB,EAAE;IAC/D,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,GAAG,WAAW,CAAC,MAAM,CAAC,CAAC;IAElD,IAAI,CAAC,aAAa,CAAC,MAAM,CAAC,EAAE,CAAC;QAC5B,MAAM,IAAI,KAAK,CAAC,sBAAsB,CAAC,CAAC;IACzC,CAAC;IAED,MAAM,IAAI,GAAG,MAAM,CAAC,WAAW,CAAC,CAAC;IAEjC,IAAI,MAAM,CAAC,EAAE,KAAK,CAAC,EAAE,CAAC;QACrB,OAAO;YACN,IAAI,EAAE,SAAS;YACf,IAAI;YACJ,aAAa,EAAE,MAAM,CAAC,CAAC;SACvB,CAAC;IACH,CAAC;SAAM,CAAC;QACP,MAAM,SAAS,GAAG,IAAsB,CAAC;QACzC,OAAO;YACN,IAAI,EAAE,OAAO;YACb,KAAK,EAAE,SAAS,CAAC,KAAK;YACtB,OAAO,EAAE,SAAS,CAAC,OAAO;SAC1B,CAAC;IACH,CAAC;AACF,CAAC,CAAC;AAEF;;GAEG;AACH,MAAM,aAAa,GAAG,CAAC,KAAc,EAAwB,EAAE;IAC9D,IAAI,KAAK,KAAK,IAAI,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QACjD,OAAO,KAAK,CAAC;IACd,CAAC;IAED,MAAM,GAAG,GAAG,KAAgC,CAAC;IAE7C,OAAO,CAAC,GAAG,CAAC,EAAE,KAAK,CAAC,IAAI,GAAG,CAAC,EAAE,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,KAAK,SAAS,IAAI,OAAO,GAAG,CAAC,CAAC,KAAK,QAAQ,CAAC,CAAC;AAC9F,CAAC,CAAC;AAEF;;GAEG;AACH,MAAM,CAAC,MAAM,eAAe,GAAG,CAAC,aAAiC,EAAE,IAAY,EAAsB,EAAE;IACtG,IAAI,CAAC,aAAa,EAAE,CAAC;QACpB,OAAO,SAAS,CAAC;IAClB,CAAC;IAED,IAAI,aAAa,CAAC,CAAC,CAAC,KAAK,GAAG,EAAE,CAAC;QAC9B,OAAO,IAAI,GAAG,aAAa,CAAC;IAC7B,CAAC;IAED,OAAO,aAAa,CAAC;AACtB,CAAC,CAAC;AAEF;;GAEG;AACH,MAAM,CAAC,MAAM,aAAa,GAAG,CAAC,IAAa,EAAE,aAAiC,EAAE,IAAY,EAAW,EAAE;IACxG,MAAM,IAAI,GAAG,eAAe,CAAC,aAAa,EAAE,IAAI,CAAC,CAAC;IAElD,IAAI,IAAI,IAAI,OAAO,IAAI,KAAK,QAAQ,IAAI,IAAI,KAAK,IAAI,EAAE,CAAC;QACtD,IAAgC,CAAC,KAAK,GAAG,IAAI,CAAC;IAChD,CAAC;IAED,OAAO,IAAI,CAAC;AACb,CAAC,CAAC"}
@@ -0,0 +1,3 @@
1
+ export { FirehoseSubscription } from './subscription.js';
2
+ export type { FirehoseSubscriptionOptions, MessageOf, ParamsOf } from './types.js';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../lib/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AACzD,YAAY,EAAE,2BAA2B,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { FirehoseSubscription } from './subscription.js';
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../lib/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAC"}
@@ -0,0 +1,24 @@
1
+ import { type XRPCSubscriptionMetadata } from '@atcute/lexicons/validations';
2
+ import { EventIterator } from '@mary-ext/event-iterator';
3
+ import type { ReadonlyDeep } from 'type-fest';
4
+ import type { FirehoseSubscriptionOptions, MessageOf } from './types.js';
5
+ /**
6
+ * generic XRPC subscription client for AT Protocol
7
+ */
8
+ export declare class FirehoseSubscription<TSchema extends XRPCSubscriptionMetadata> {
9
+ #private;
10
+ /**
11
+ * creates a new firehose subscription
12
+ */
13
+ constructor(options: FirehoseSubscriptionOptions<TSchema>);
14
+ [Symbol.asyncIterator](): EventIterator<MessageOf<TSchema>>;
15
+ /**
16
+ * get current subscription options
17
+ */
18
+ getOptions(): ReadonlyDeep<FirehoseSubscriptionOptions<TSchema>>;
19
+ /**
20
+ * update subscription options, triggering a reconnection if currently connected
21
+ */
22
+ updateOptions(options: Partial<FirehoseSubscriptionOptions<TSchema>>): void;
23
+ }
24
+ //# sourceMappingURL=subscription.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"subscription.d.ts","sourceRoot":"","sources":["../lib/subscription.ts"],"names":[],"mappings":"AAAA,OAAO,EAAa,KAAK,wBAAwB,EAAE,MAAM,8BAA8B,CAAC;AAExF,OAAO,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAC;AAKzD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AAG9C,OAAO,KAAK,EAAE,2BAA2B,EAAE,SAAS,EAAY,MAAM,YAAY,CAAC;AAEnF;;GAEG;AACH,qBAAa,oBAAoB,CAAC,OAAO,SAAS,wBAAwB;;IAQzE;;OAEG;gBACS,OAAO,EAAE,2BAA2B,CAAC,OAAO,CAAC;IA4FzD,CAAC,MAAM,CAAC,aAAa,CAAC;IAoBtB;;OAEG;IACH,UAAU,IAAI,YAAY,CAAC,2BAA2B,CAAC,OAAO,CAAC,CAAC;IAIhE;;OAEG;IACH,aAAa,CAAC,OAAO,EAAE,OAAO,CAAC,2BAA2B,CAAC,OAAO,CAAC,CAAC,GAAG,IAAI;CAQ3E"}
@@ -0,0 +1,112 @@
1
+ import { safeParse } from '@atcute/lexicons/validations';
2
+ import { EventIterator } from '@mary-ext/event-iterator';
3
+ import { SimpleEventEmitter } from '@mary-ext/simple-event-emitter';
4
+ import { WebSocket as ReconnectingWebSocket } from 'partysocket';
5
+ import { addTypeToBody, decodeFrame } from './frame-decoder.js';
6
+ /**
7
+ * generic XRPC subscription client for AT Protocol
8
+ */
9
+ export class FirehoseSubscription {
10
+ #listening = 0;
11
+ #ws;
12
+ #emitter = new SimpleEventEmitter();
13
+ #options;
14
+ /**
15
+ * creates a new firehose subscription
16
+ */
17
+ constructor(options) {
18
+ this.#options = options;
19
+ }
20
+ #create() {
21
+ if (this.#ws !== undefined) {
22
+ return;
23
+ }
24
+ const { service: wsUrls, nsid, params, ws: wsOptions, validateMessages = true, onConnectionClose, onConnectionError, onConnectionOpen, onError, } = this.#options;
25
+ const emitter = this.#emitter;
26
+ const getUrl = () => {
27
+ let selectedUrl;
28
+ if (typeof wsUrls === 'string') {
29
+ selectedUrl = wsUrls;
30
+ }
31
+ else {
32
+ selectedUrl = wsUrls[Math.floor(Math.random() * wsUrls.length)];
33
+ }
34
+ const url = new URL('/xrpc/' + nsid.nsid, selectedUrl);
35
+ const currentParams = typeof params === 'function' ? params() : params;
36
+ if (currentParams !== undefined && currentParams !== null) {
37
+ const paramObj = currentParams;
38
+ for (const key in paramObj) {
39
+ const value = paramObj[key];
40
+ if (value !== undefined && value !== null) {
41
+ url.searchParams.set(key, String(value));
42
+ }
43
+ }
44
+ }
45
+ return url.toString();
46
+ };
47
+ const ws = new ReconnectingWebSocket(getUrl, null, wsOptions);
48
+ this.#ws = ws;
49
+ ws.binaryType = 'arraybuffer';
50
+ ws.onerror = onConnectionError ?? null;
51
+ ws.onclose = onConnectionClose ?? null;
52
+ ws.onopen = onConnectionOpen ?? null;
53
+ ws.onmessage = (ev) => {
54
+ const buffer = new Uint8Array(ev.data);
55
+ const frame = decodeFrame(buffer);
56
+ if (frame.type === 'error') {
57
+ onError?.(frame.error, frame.message);
58
+ return;
59
+ }
60
+ let body = addTypeToBody(frame.body, frame.discriminator, nsid.nsid);
61
+ if (validateMessages && nsid.message !== null) {
62
+ const result = safeParse(nsid.message, body);
63
+ if (!result.ok) {
64
+ return;
65
+ }
66
+ body = result.value;
67
+ }
68
+ emitter.emit(body);
69
+ };
70
+ }
71
+ #destroy() {
72
+ const ws = this.#ws;
73
+ if (ws === undefined) {
74
+ return;
75
+ }
76
+ ws.close();
77
+ this.#ws = undefined;
78
+ }
79
+ [Symbol.asyncIterator]() {
80
+ return new EventIterator((emit) => {
81
+ if (this.#listening === 0) {
82
+ this.#create();
83
+ }
84
+ this.#listening++;
85
+ this.#emitter.subscribe(emit);
86
+ return () => {
87
+ if (this.#listening === 1) {
88
+ this.#destroy();
89
+ }
90
+ this.#listening--;
91
+ this.#emitter.unsubscribe(emit);
92
+ };
93
+ });
94
+ }
95
+ /**
96
+ * get current subscription options
97
+ */
98
+ getOptions() {
99
+ return this.#options;
100
+ }
101
+ /**
102
+ * update subscription options, triggering a reconnection if currently connected
103
+ */
104
+ updateOptions(options) {
105
+ this.#options = { ...this.#options, ...options };
106
+ if (this.#ws !== undefined) {
107
+ this.#destroy();
108
+ this.#create();
109
+ }
110
+ }
111
+ }
112
+ //# sourceMappingURL=subscription.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"subscription.js","sourceRoot":"","sources":["../lib/subscription.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,76 @@
1
+ import type { InferOutput, XRPCSubscriptionMetadata } from '@atcute/lexicons/validations';
2
+ import type { CloseEvent, ErrorEvent, Options } from 'partysocket/ws';
3
+ import type { BaseSchema } from '@atcute/lexicons/validations';
4
+ /**
5
+ * extracts the params type from an XRPC subscription schema
6
+ */
7
+ export type ParamsOf<T> = T extends XRPCSubscriptionMetadata<infer TParams, any, any> ? TParams extends null ? undefined : TParams extends BaseSchema ? InferOutput<TParams> : never : never;
8
+ /**
9
+ * extracts the message type from an XRPC subscription schema
10
+ */
11
+ export type MessageOf<T> = T extends XRPCSubscriptionMetadata<any, infer TMessage, any> ? TMessage extends null ? unknown : TMessage extends BaseSchema ? InferOutput<TMessage> : never : never;
12
+ /**
13
+ * configuration options for FirehoseSubscription
14
+ */
15
+ export interface FirehoseSubscriptionOptions<TSchema extends XRPCSubscriptionMetadata> {
16
+ /**
17
+ * XRPC service URL(s) to connect to
18
+ */
19
+ service: string | string[];
20
+ /**
21
+ * XRPC subscription schema from @atcute/lexicons
22
+ */
23
+ nsid: TSchema;
24
+ /**
25
+ * subscription parameters - can be a static object or a function that returns
26
+ * params. the function is called on each connection attempt, allowing for
27
+ * dynamic cursor tracking and reconnection state management.
28
+ */
29
+ params?: ParamsOf<TSchema> | (() => ParamsOf<TSchema>);
30
+ /**
31
+ * whether to validate incoming messages against the schema
32
+ * @default true
33
+ */
34
+ validateMessages?: boolean;
35
+ onConnectionOpen?: (event: Event) => void;
36
+ onConnectionClose?: (event: CloseEvent) => void;
37
+ onConnectionError?: (event: ErrorEvent) => void;
38
+ onError?: (error: string, message?: string) => void;
39
+ /**
40
+ * WebSocket connection options
41
+ */
42
+ ws?: Options;
43
+ }
44
+ /**
45
+ * decoded CBOR frame header
46
+ */
47
+ export interface FrameHeader {
48
+ /**
49
+ * operation code: 1 for message, -1 for error
50
+ */
51
+ op: 1 | -1;
52
+ /**
53
+ * type discriminator for message frames (relative to NSID, e.g., "#commit")
54
+ */
55
+ t?: string;
56
+ }
57
+ /**
58
+ * error frame body
59
+ */
60
+ export interface ErrorFrameBody {
61
+ error: string;
62
+ message?: string;
63
+ }
64
+ /**
65
+ * decoded frame result
66
+ */
67
+ export type DecodedFrame = {
68
+ type: 'message';
69
+ body: unknown;
70
+ discriminator?: string;
71
+ } | {
72
+ type: 'error';
73
+ error: string;
74
+ message?: string;
75
+ };
76
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../lib/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,wBAAwB,EAAE,MAAM,8BAA8B,CAAC;AAE1F,OAAO,KAAK,EAAE,UAAU,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,gBAAgB,CAAC;AAEtE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,8BAA8B,CAAC;AAE/D;;GAEG;AACH,MAAM,MAAM,QAAQ,CAAC,CAAC,IAAI,CAAC,SAAS,wBAAwB,CAAC,MAAM,OAAO,EAAE,GAAG,EAAE,GAAG,CAAC,GAClF,OAAO,SAAS,IAAI,GACnB,SAAS,GACT,OAAO,SAAS,UAAU,GACzB,WAAW,CAAC,OAAO,CAAC,GACpB,KAAK,GACP,KAAK,CAAC;AAET;;GAEG;AACH,MAAM,MAAM,SAAS,CAAC,CAAC,IAAI,CAAC,SAAS,wBAAwB,CAAC,GAAG,EAAE,MAAM,QAAQ,EAAE,GAAG,CAAC,GACpF,QAAQ,SAAS,IAAI,GACpB,OAAO,GACP,QAAQ,SAAS,UAAU,GAC1B,WAAW,CAAC,QAAQ,CAAC,GACrB,KAAK,GACP,KAAK,CAAC;AAET;;GAEG;AACH,MAAM,WAAW,2BAA2B,CAAC,OAAO,SAAS,wBAAwB;IACpF;;OAEG;IACH,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IAE3B;;OAEG;IACH,IAAI,EAAE,OAAO,CAAC;IAEd;;;;OAIG;IACH,MAAM,CAAC,EAAE,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,MAAM,QAAQ,CAAC,OAAO,CAAC,CAAC,CAAC;IAEvD;;;OAGG;IACH,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAE3B,gBAAgB,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC;IAC1C,iBAAiB,CAAC,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,IAAI,CAAC;IAChD,iBAAiB,CAAC,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,IAAI,CAAC;IAChD,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;IAEpD;;OAEG;IACH,EAAE,CAAC,EAAE,OAAO,CAAC;CACb;AAED;;GAEG;AACH,MAAM,WAAW,WAAW;IAC3B;;OAEG;IACH,EAAE,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC;IAEX;;OAEG;IACH,CAAC,CAAC,EAAE,MAAM,CAAC;CACX;AAED;;GAEG;AACH,MAAM,WAAW,cAAc;IAC9B,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;GAEG;AACH,MAAM,MAAM,YAAY,GACrB;IACA,IAAI,EAAE,SAAS,CAAC;IAChB,IAAI,EAAE,OAAO,CAAC;IACd,aAAa,CAAC,EAAE,MAAM,CAAC;CACtB,GACD;IACA,IAAI,EAAE,OAAO,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC"}
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../lib/types.ts"],"names":[],"mappings":""}
@@ -0,0 +1,72 @@
1
+ import { decode, decodeFirst } from '@atcute/cbor';
2
+
3
+ import type { DecodedFrame, ErrorFrameBody, FrameHeader } from './types.js';
4
+
5
+ /**
6
+ * decodes a CBOR frame from a buffer
7
+ */
8
+ export const decodeFrame = (buffer: Uint8Array): DecodedFrame => {
9
+ const [header, afterHeader] = decodeFirst(buffer);
10
+
11
+ if (!isValidHeader(header)) {
12
+ throw new Error('invalid frame header');
13
+ }
14
+
15
+ const body = decode(afterHeader);
16
+
17
+ if (header.op === 1) {
18
+ return {
19
+ type: 'message',
20
+ body,
21
+ discriminator: header.t,
22
+ };
23
+ } else {
24
+ const errorBody = body as ErrorFrameBody;
25
+ return {
26
+ type: 'error',
27
+ error: errorBody.error,
28
+ message: errorBody.message,
29
+ };
30
+ }
31
+ };
32
+
33
+ /**
34
+ * type guard for frame header
35
+ */
36
+ const isValidHeader = (value: unknown): value is FrameHeader => {
37
+ if (value === null || typeof value !== 'object') {
38
+ return false;
39
+ }
40
+
41
+ const obj = value as Record<string, unknown>;
42
+
43
+ return (obj.op === 1 || obj.op === -1) && (obj.t === undefined || typeof obj.t === 'string');
44
+ };
45
+
46
+ /**
47
+ * reconstructs full $type field from discriminator and nsid
48
+ */
49
+ export const reconstructType = (discriminator: string | undefined, nsid: string): string | undefined => {
50
+ if (!discriminator) {
51
+ return undefined;
52
+ }
53
+
54
+ if (discriminator[0] === '#') {
55
+ return nsid + discriminator;
56
+ }
57
+
58
+ return discriminator;
59
+ };
60
+
61
+ /**
62
+ * adds $type field to message body if discriminator is present
63
+ */
64
+ export const addTypeToBody = (body: unknown, discriminator: string | undefined, nsid: string): unknown => {
65
+ const type = reconstructType(discriminator, nsid);
66
+
67
+ if (type && typeof body === 'object' && body !== null) {
68
+ (body as Record<string, unknown>).$type = type;
69
+ }
70
+
71
+ return body;
72
+ };
package/lib/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { FirehoseSubscription } from './subscription.js';
2
+ export type { FirehoseSubscriptionOptions, MessageOf, ParamsOf } from './types.js';
@@ -0,0 +1,157 @@
1
+ import { safeParse, type XRPCSubscriptionMetadata } from '@atcute/lexicons/validations';
2
+
3
+ import { EventIterator } from '@mary-ext/event-iterator';
4
+ import { SimpleEventEmitter } from '@mary-ext/simple-event-emitter';
5
+
6
+ import { WebSocket as ReconnectingWebSocket } from 'partysocket';
7
+
8
+ import type { ReadonlyDeep } from 'type-fest';
9
+
10
+ import { addTypeToBody, decodeFrame } from './frame-decoder.js';
11
+ import type { FirehoseSubscriptionOptions, MessageOf, ParamsOf } from './types.js';
12
+
13
+ /**
14
+ * generic XRPC subscription client for AT Protocol
15
+ */
16
+ export class FirehoseSubscription<TSchema extends XRPCSubscriptionMetadata> {
17
+ #listening = 0;
18
+ #ws?: ReconnectingWebSocket;
19
+
20
+ #emitter = new SimpleEventEmitter<[message: MessageOf<TSchema>]>();
21
+
22
+ #options: FirehoseSubscriptionOptions<TSchema>;
23
+
24
+ /**
25
+ * creates a new firehose subscription
26
+ */
27
+ constructor(options: FirehoseSubscriptionOptions<TSchema>) {
28
+ this.#options = options;
29
+ }
30
+
31
+ #create() {
32
+ if (this.#ws !== undefined) {
33
+ return;
34
+ }
35
+
36
+ const {
37
+ service: wsUrls,
38
+ nsid,
39
+ params,
40
+ ws: wsOptions,
41
+ validateMessages = true,
42
+ onConnectionClose,
43
+ onConnectionError,
44
+ onConnectionOpen,
45
+ onError,
46
+ } = this.#options;
47
+
48
+ const emitter = this.#emitter;
49
+
50
+ const getUrl = () => {
51
+ let selectedUrl: string;
52
+
53
+ if (typeof wsUrls === 'string') {
54
+ selectedUrl = wsUrls;
55
+ } else {
56
+ selectedUrl = wsUrls[Math.floor(Math.random() * wsUrls.length)];
57
+ }
58
+
59
+ const url = new URL('/xrpc/' + nsid.nsid, selectedUrl);
60
+
61
+ const currentParams: ParamsOf<TSchema> = typeof params === 'function' ? params() : params;
62
+
63
+ if (currentParams !== undefined && currentParams !== null) {
64
+ const paramObj = currentParams as Record<string, unknown>;
65
+ for (const key in paramObj) {
66
+ const value = paramObj[key];
67
+ if (value !== undefined && value !== null) {
68
+ url.searchParams.set(key, String(value));
69
+ }
70
+ }
71
+ }
72
+
73
+ return url.toString();
74
+ };
75
+
76
+ const ws = new ReconnectingWebSocket(getUrl, null, wsOptions);
77
+ this.#ws = ws;
78
+
79
+ ws.binaryType = 'arraybuffer';
80
+
81
+ ws.onerror = onConnectionError ?? null;
82
+ ws.onclose = onConnectionClose ?? null;
83
+ ws.onopen = onConnectionOpen ?? null;
84
+
85
+ ws.onmessage = (ev) => {
86
+ const buffer = new Uint8Array(ev.data);
87
+ const frame = decodeFrame(buffer);
88
+
89
+ if (frame.type === 'error') {
90
+ onError?.(frame.error, frame.message);
91
+ return;
92
+ }
93
+
94
+ let body = addTypeToBody(frame.body, frame.discriminator, nsid.nsid);
95
+
96
+ if (validateMessages && nsid.message !== null) {
97
+ const result = safeParse(nsid.message, body);
98
+ if (!result.ok) {
99
+ return;
100
+ }
101
+ body = result.value;
102
+ }
103
+
104
+ emitter.emit(body as MessageOf<TSchema>);
105
+ };
106
+ }
107
+
108
+ #destroy() {
109
+ const ws = this.#ws;
110
+ if (ws === undefined) {
111
+ return;
112
+ }
113
+
114
+ ws.close();
115
+
116
+ this.#ws = undefined;
117
+ }
118
+
119
+ [Symbol.asyncIterator]() {
120
+ return new EventIterator<MessageOf<TSchema>>((emit) => {
121
+ if (this.#listening === 0) {
122
+ this.#create();
123
+ }
124
+
125
+ this.#listening++;
126
+ this.#emitter.subscribe(emit);
127
+
128
+ return () => {
129
+ if (this.#listening === 1) {
130
+ this.#destroy();
131
+ }
132
+
133
+ this.#listening--;
134
+ this.#emitter.unsubscribe(emit);
135
+ };
136
+ });
137
+ }
138
+
139
+ /**
140
+ * get current subscription options
141
+ */
142
+ getOptions(): ReadonlyDeep<FirehoseSubscriptionOptions<TSchema>> {
143
+ return this.#options as ReadonlyDeep<FirehoseSubscriptionOptions<TSchema>>;
144
+ }
145
+
146
+ /**
147
+ * update subscription options, triggering a reconnection if currently connected
148
+ */
149
+ updateOptions(options: Partial<FirehoseSubscriptionOptions<TSchema>>): void {
150
+ this.#options = { ...this.#options, ...options };
151
+
152
+ if (this.#ws !== undefined) {
153
+ this.#destroy();
154
+ this.#create();
155
+ }
156
+ }
157
+ }
package/lib/types.ts ADDED
@@ -0,0 +1,103 @@
1
+ import type { InferOutput, XRPCSubscriptionMetadata } from '@atcute/lexicons/validations';
2
+
3
+ import type { CloseEvent, ErrorEvent, Options } from 'partysocket/ws';
4
+
5
+ import type { BaseSchema } from '@atcute/lexicons/validations';
6
+
7
+ /**
8
+ * extracts the params type from an XRPC subscription schema
9
+ */
10
+ export type ParamsOf<T> = T extends XRPCSubscriptionMetadata<infer TParams, any, any>
11
+ ? TParams extends null
12
+ ? undefined
13
+ : TParams extends BaseSchema
14
+ ? InferOutput<TParams>
15
+ : never
16
+ : never;
17
+
18
+ /**
19
+ * extracts the message type from an XRPC subscription schema
20
+ */
21
+ export type MessageOf<T> = T extends XRPCSubscriptionMetadata<any, infer TMessage, any>
22
+ ? TMessage extends null
23
+ ? unknown
24
+ : TMessage extends BaseSchema
25
+ ? InferOutput<TMessage>
26
+ : never
27
+ : never;
28
+
29
+ /**
30
+ * configuration options for FirehoseSubscription
31
+ */
32
+ export interface FirehoseSubscriptionOptions<TSchema extends XRPCSubscriptionMetadata> {
33
+ /**
34
+ * XRPC service URL(s) to connect to
35
+ */
36
+ service: string | string[];
37
+
38
+ /**
39
+ * XRPC subscription schema from @atcute/lexicons
40
+ */
41
+ nsid: TSchema;
42
+
43
+ /**
44
+ * subscription parameters - can be a static object or a function that returns
45
+ * params. the function is called on each connection attempt, allowing for
46
+ * dynamic cursor tracking and reconnection state management.
47
+ */
48
+ params?: ParamsOf<TSchema> | (() => ParamsOf<TSchema>);
49
+
50
+ /**
51
+ * whether to validate incoming messages against the schema
52
+ * @default true
53
+ */
54
+ validateMessages?: boolean;
55
+
56
+ onConnectionOpen?: (event: Event) => void;
57
+ onConnectionClose?: (event: CloseEvent) => void;
58
+ onConnectionError?: (event: ErrorEvent) => void;
59
+ onError?: (error: string, message?: string) => void;
60
+
61
+ /**
62
+ * WebSocket connection options
63
+ */
64
+ ws?: Options;
65
+ }
66
+
67
+ /**
68
+ * decoded CBOR frame header
69
+ */
70
+ export interface FrameHeader {
71
+ /**
72
+ * operation code: 1 for message, -1 for error
73
+ */
74
+ op: 1 | -1;
75
+
76
+ /**
77
+ * type discriminator for message frames (relative to NSID, e.g., "#commit")
78
+ */
79
+ t?: string;
80
+ }
81
+
82
+ /**
83
+ * error frame body
84
+ */
85
+ export interface ErrorFrameBody {
86
+ error: string;
87
+ message?: string;
88
+ }
89
+
90
+ /**
91
+ * decoded frame result
92
+ */
93
+ export type DecodedFrame =
94
+ | {
95
+ type: 'message';
96
+ body: unknown;
97
+ discriminator?: string;
98
+ }
99
+ | {
100
+ type: 'error';
101
+ error: string;
102
+ message?: string;
103
+ };
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "type": "module",
3
+ "name": "@atcute/firehose",
4
+ "version": "0.1.0",
5
+ "description": "lightweight and cute XRPC subscription client for AT Protocol",
6
+ "license": "0BSD",
7
+ "repository": {
8
+ "url": "https://github.com/mary-ext/atcute",
9
+ "directory": "packages/clients/firehose"
10
+ },
11
+ "files": [
12
+ "dist/",
13
+ "lib/",
14
+ "!lib/**/*.bench.ts",
15
+ "!lib/**/*.test.ts"
16
+ ],
17
+ "exports": {
18
+ ".": "./dist/index.js"
19
+ },
20
+ "dependencies": {
21
+ "@mary-ext/event-iterator": "^1.0.0",
22
+ "@mary-ext/simple-event-emitter": "^1.0.0",
23
+ "partysocket": "^1.1.6",
24
+ "type-fest": "^4.41.0",
25
+ "@atcute/lexicons": "^1.2.2",
26
+ "@atcute/cbor": "^2.2.7",
27
+ "@atcute/uint8array": "^1.0.5"
28
+ },
29
+ "devDependencies": {
30
+ "@atcute/atproto": "^3.1.8"
31
+ },
32
+ "scripts": {
33
+ "build": "tsc --project tsconfig.build.json",
34
+ "prepublish": "rm -rf dist; pnpm run build"
35
+ }
36
+ }