felix-client 0.5.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.
Files changed (5) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +184 -0
  3. package/index.d.ts +422 -0
  4. package/index.js +252 -0
  5. package/package.json +62 -0
package/LICENSE ADDED
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
package/README.md ADDED
@@ -0,0 +1,184 @@
1
+ # felix-client for Node.js and TypeScript
2
+
3
+ Node bindings for Felix, built as a wrapper over the Rust client rather than a
4
+ reimplementation of the protocol.
5
+
6
+ That distinction is the point. Reconnection, redirect-following, retry
7
+ classification and offset bookkeeping are hard to get right and expensive to
8
+ get wrong — a second implementation is a second set of subtle bugs in exactly
9
+ the places that matter. Here they exist once, in `felix-client`, and every
10
+ language binds to them. The Python binding is built on the same reasoning and
11
+ exposes the same surface.
12
+
13
+ ## One surface, and it is asynchronous
14
+
15
+ Python offers two surfaces because its sync one is the older idiom. Node has no
16
+ such split: blocking the event loop is not something a library may do, so every
17
+ call here returns a `Promise`. napi-rs runs the future on its own Tokio runtime
18
+ and settles the promise from there, which keeps the event loop free while a
19
+ publish is in flight.
20
+
21
+ ```ts
22
+ import { Client } from "felix-client";
23
+
24
+ const client = await Client.connect("127.0.0.1:5000", "t1", token, "localhost", caFile);
25
+
26
+ await client.publish("t1", "default", "events", Buffer.from("hello"));
27
+
28
+ const events = await client.subscribe("t1", "default", "events");
29
+ for (;;) {
30
+ const event = await events.nextEvent();
31
+ if (event === null) break;
32
+ console.log(event.payload.toString(), event.offset);
33
+ }
34
+ await events.close();
35
+ ```
36
+
37
+ ## What it wraps
38
+
39
+ Everything the Python binding wraps, with one exception noted below.
40
+
41
+ | | |
42
+ |---|---|
43
+ | Publish | `publish(tenant, ns, stream, payload, key?, ack?, atLeastOnce?)` |
44
+ | Subscribe | `subscribe(...)` → `nextEvent()`, `close()`, `closed` |
45
+ | Sharded subscribe | `subscribeSharded(..., start?, resume?)` → `nextEvent()`, `positions()`, `shards` |
46
+ | Stream shape | `streamShards(...)`, `endpoints()` |
47
+ | Cache | `cachePut` (with TTL), `cacheGet`, `cacheDelete` |
48
+ | Counters | `counterAdd`, `counterGet` |
49
+ | Cache watches | `watchCache(..., key?, prefix?, start?, retained?)` → `recv()`, `retainedCount` |
50
+ | Consumer groups | `groupPoll`, `groupAck`, `groupNack`, `groupDeadLetters`, `groupDiscard`, `groupRedrive` |
51
+
52
+ Every handle has an idempotent `close()` and implements `Symbol.asyncDispose`,
53
+ so on Node 24 and newer a `throw` releases it on the way out:
54
+
55
+ ```ts
56
+ await using events = await client.subscribe("t1", "default", "events");
57
+ ```
58
+
59
+ The package itself asks only for Node 18 — `await using` is the syntax that
60
+ needs the newer runtime, not the disposal.
61
+
62
+ ### Routing keys decide the shard
63
+
64
+ `publish` takes an optional key. Without one every record lands on shard 0, so
65
+ a multi-shard stream behaves like a single-shard one:
66
+
67
+ ```ts
68
+ await client.publish("t1", "default", "orders", payload, Buffer.from(customerId));
69
+ ```
70
+
71
+ Records sharing a key share a shard and stay ordered with respect to each
72
+ other. Records with different keys do not, once a stream has more than one
73
+ shard.
74
+
75
+ ### Offsets are how you notice a drop
76
+
77
+ Subscriber queues shed under the default policy rather than blocking the
78
+ publisher, so a subscriber can silently miss records. On a durable stream each
79
+ delivered event carries its log offset, and a jump in them is exactly a drop —
80
+ which is why `event.offset` is worth reading even when you do not resume from
81
+ it.
82
+
83
+ ### A sharded subscription surfaces shard trouble rather than hiding it
84
+
85
+ `subscribeSharded` merges every shard, and each item says which shard it came
86
+ from. A lost shard arrives as an item of its own and does not disturb the
87
+ others: they keep delivering, and the lost one resumes from its own last offset
88
+ so nothing is skipped. Per-shard ordering is all a sharded stream has, and the
89
+ handle does not pretend otherwise.
90
+
91
+ ### TLS is not optional
92
+
93
+ QUIC has no unencrypted mode, so there are two trust choices and no third: the
94
+ platform trust store (omit `caFile`) or an explicit CA file (what a self-signed
95
+ development broker needs). There is deliberately no "skip verification" switch
96
+ — it is the one setting that silently turns a secure deployment insecure, and a
97
+ CA file covers the development case without it.
98
+
99
+ ### At-least-once publishing duplicates, and says so
100
+
101
+ By default a publish whose outcome was ambiguous — the broker may or may not
102
+ have written it before the connection went — is **reported, not re-sent**.
103
+ Nothing downstream can tell two copies apart, so re-sending silently changes
104
+ the delivery guarantee.
105
+
106
+ ```ts
107
+ await client.publish("t1", "default", "events", payload, undefined, "per_message", true);
108
+ ```
109
+
110
+ That is the opt-in. The record is then certain to land and **may land twice**.
111
+ It cannot be combined with a routing key: the re-send path does not carry one
112
+ yet, and honouring the key on the first attempt but not the re-send would move
113
+ the duplicate to a different shard, so the combination is refused rather than
114
+ resolved.
115
+
116
+ ### Errors carry an identity, not just a message
117
+
118
+ ```ts
119
+ try {
120
+ await client.publish("t1", "default", "events", payload);
121
+ } catch (err) {
122
+ if (err instanceof ConnectionError) retry(); // err.retryable === true
123
+ else if (err instanceof AuthError) giveUp(); // no amount of retrying grants a permission
124
+ }
125
+ ```
126
+
127
+ `FelixError` is the base; `ConnectionError`, `AuthError`, `NotFoundError`,
128
+ `CursorError` and `InvalidArgumentError` are the branches, mirroring the Python
129
+ binding's exceptions. Each carries a stable `code` as well, for code that would
130
+ rather switch than test `instanceof`. Never match on the message — it is prose,
131
+ and it will be reworded.
132
+
133
+ ## What is not wrapped
134
+
135
+ - **Idempotent producers** (`producer_init` / `publish_idempotent`). They turn
136
+ an ambiguous publish into one the broker can recognise as a re-send and
137
+ refuse to append twice — at-least-once without the duplication. Surface over
138
+ a primitive that already exists, not protocol work.
139
+
140
+ ## Conformance
141
+
142
+ This binding runs the client conformance suite and passes every required
143
+ scenario in the catalogue, which is what makes "the Node client behaves like
144
+ the Rust one" a checked claim rather than an intention.
145
+
146
+ The suite is not a mock. `felix-cluster client-fixture` starts a real
147
+ three-node cluster, and the tests drive it: a redirect needs a broker that does
148
+ not own the shard, and `reconnect.survives_broker_loss` kills the broker its
149
+ client is connected to. Each test names the scenarios it demonstrates; the run
150
+ writes a results document, and `felix-conformance verify` checks it against the
151
+ catalogue. A test that fails, errors, or never runs becomes a non-passing
152
+ outcome, so a green run that quietly skipped a required scenario is still
153
+ reported as non-conformant.
154
+
155
+ ```bash
156
+ task ts:conformance # build what it needs, run it, verify the verdict
157
+ task conformance:scenarios # the catalogue itself
158
+ ```
159
+
160
+ Two optional scenarios are recorded as skipped with their reason:
161
+ idempotent producers, which this binding does not wrap, and
162
+ `error.bad_offset_is_typed`, which needs a trimmed log that a client has no way
163
+ to produce.
164
+
165
+ ## Building
166
+
167
+ The crate lives outside the workspace, like `felix-python` and the crates under
168
+ `demos/`: a Node addon is a `cdylib` whose Node symbols the host process
169
+ resolves at load time, which is correct for an addon and fatal for a test
170
+ binary linked by `cargo test --workspace`.
171
+
172
+ ```bash
173
+ task ts:check # fmt, clippy, build — what CI runs
174
+ task ts:build # napi build --release (needs `npm i -g @napi-rs/cli@2`)
175
+ ```
176
+
177
+ Without the napi CLI you can still load the addon, because `napi build` is
178
+ mostly a rename: build the `cdylib` and point Node at it directly.
179
+
180
+ ```bash
181
+ cargo build --release
182
+ cp ../../target/release/libfelix_typescript.dylib ./felix.node # .so on Linux
183
+ node -e "console.log(Object.keys(require('./felix.node')))"
184
+ ```
package/index.d.ts ADDED
@@ -0,0 +1,422 @@
1
+ // Hand-written to stay readable. `napi build` also emits a generated
2
+ // `index.d.ts`; this file is the checked-in contract and the generated one is
3
+ // expected to agree with it. `task ts:check` is where a drift shows up.
4
+
5
+ /// <reference types="node" />
6
+
7
+ /** One delivered record. */
8
+ export interface Event {
9
+ tenantId: string;
10
+ namespace: string;
11
+ stream: string;
12
+ payload: Buffer;
13
+ /**
14
+ * The record's log offset on a durable stream, absent on an ephemeral one.
15
+ *
16
+ * A jump in these is exactly a drop: subscriber queues shed under the
17
+ * default policy rather than blocking the publisher, so a gap here is the
18
+ * signal that it happened.
19
+ */
20
+ offset: bigint | null;
21
+ }
22
+
23
+ /** One record handed out by a consumer group. */
24
+ export interface GroupRecord {
25
+ /** What to pass to `groupAck` or `groupNack` to settle this record. */
26
+ offset: bigint;
27
+ payload: Buffer;
28
+ /**
29
+ * How many times this record has been handed out, this delivery included.
30
+ * `1` is a first attempt; anything higher is a redelivery, so a consumer can
31
+ * treat a retry differently. `0` means the broker did not report it.
32
+ */
33
+ attempts: number;
34
+ }
35
+
36
+ /** One change observed by a cache watch. */
37
+ export interface CacheChange {
38
+ key: string;
39
+ /** `null` when the key was deleted or expired, which is not `Buffer.alloc(0)`. */
40
+ value: Buffer | null;
41
+ /** This change's cache-log offset. Re-watch from `offset + 1n` to resume. */
42
+ offset: bigint;
43
+ expiresAtMillis: bigint;
44
+ }
45
+
46
+ /**
47
+ * An item from a cache watch: a change, or notice that the watch fell behind.
48
+ *
49
+ * Exactly one field is set. Lag is a value rather than a rejection because it
50
+ * is not a failure — the watch did its job by saying so — and a filtered
51
+ * watch's offsets are sparse by construction, so loss cannot be inferred the
52
+ * way a stream subscriber infers it.
53
+ */
54
+ export interface CacheWatchItem {
55
+ change: CacheChange | null;
56
+ /**
57
+ * Set when the watch lagged and the broker ended it. Re-watching with
58
+ * `start = laggedResumeFrom` is gapless.
59
+ */
60
+ laggedResumeFrom: bigint | null;
61
+ }
62
+
63
+ /**
64
+ * An item from a sharded subscription.
65
+ *
66
+ * Exactly one of `event`, `lostError` and `recovered` is set, and `shard` says
67
+ * which shard it concerns. A lost shard does not affect the others: they keep
68
+ * delivering while that one is re-established, and it resumes from its own
69
+ * last offset so nothing is skipped.
70
+ */
71
+ export interface ShardEvent {
72
+ shard: number;
73
+ event: Event | null;
74
+ lostError: string | null;
75
+ recovered: boolean | null;
76
+ }
77
+
78
+ /** How much the broker must have done before a publish resolves. */
79
+ export type AckMode = "none" | "per_message" | "per_batch";
80
+
81
+ /** Where a new subscription begins. */
82
+ export type StartPosition = "latest" | "earliest" | bigint;
83
+
84
+ /** Per-shard offsets, keyed by shard number. */
85
+ export type ShardPositions = Record<string, bigint>;
86
+
87
+ /** Base class for every error this client raises. */
88
+ export declare class FelixError extends Error {
89
+ /**
90
+ * A stable identifier for *why* this failed. Branch on this, or on the
91
+ * class, rather than on the message — the message is prose and will be
92
+ * reworded.
93
+ */
94
+ readonly code: string;
95
+ /** Whether retrying could plausibly succeed. Only `ConnectionError` says yes. */
96
+ readonly retryable: boolean;
97
+ }
98
+
99
+ /** The broker could not be reached, or the connection was lost mid-call. */
100
+ export declare class ConnectionError extends FelixError {}
101
+ /** The token was rejected, or lacks the permission this call needs. */
102
+ export declare class AuthError extends FelixError {}
103
+ /** The tenant, namespace, stream or cache does not exist on the broker. */
104
+ export declare class NotFoundError extends FelixError {}
105
+ /** The requested start offset is gone — retention discarded it. */
106
+ export declare class CursorError extends FelixError {}
107
+ /** A bad argument to this client, rather than a failure of the call. */
108
+ export declare class InvalidArgumentError extends FelixError {}
109
+
110
+ /** A live subscription. Read it with `nextEvent`, and `close` it when done. */
111
+ export declare class SubscriptionHandle {
112
+ /**
113
+ * The next record, or `null` once the subscription has ended.
114
+ *
115
+ * There is no timeout argument because a caller that wants one can race this
116
+ * promise against a timer — but the losing `nextEvent` stays in flight and
117
+ * will resolve with the next record, so keep the promise rather than calling
118
+ * again.
119
+ */
120
+ nextEvent(): Promise<Event | null>;
121
+ /** Release the subscription. Idempotent. */
122
+ close(): Promise<void>;
123
+ readonly closed: boolean;
124
+ [Symbol.asyncDispose](): Promise<void>;
125
+ }
126
+
127
+ /** A subscription across every shard of a stream. */
128
+ export declare class ShardedSubscriptionHandle {
129
+ /** How many shards this subscription covers. */
130
+ readonly shards: number;
131
+ /** The next item, or `null` once every shard has ended. */
132
+ nextEvent(): Promise<ShardEvent | null>;
133
+ /**
134
+ * The last offset seen from each shard, for resuming.
135
+ *
136
+ * Only shards that have delivered something appear. Pass it back as
137
+ * `resume`: each listed shard continues at `offset + 1`, and the rest start
138
+ * wherever `start` says.
139
+ */
140
+ positions(): Promise<ShardPositions>;
141
+ close(): Promise<void>;
142
+ readonly closed: boolean;
143
+ [Symbol.asyncDispose](): Promise<void>;
144
+ }
145
+
146
+ /** A live cache watch. Close it when done. */
147
+ export declare class CacheWatchHandle {
148
+ /**
149
+ * The offset live delivery began at. Everything the broker sent before it —
150
+ * replay, or a retained snapshot — was already reflected there.
151
+ */
152
+ readonly resumeOffset: bigint;
153
+ /**
154
+ * True when the requested start predated what compaction kept, so the watch
155
+ * began from each key's current value instead of replaying history.
156
+ */
157
+ readonly resnapshot: boolean;
158
+ /**
159
+ * How many retained values arrive before live delivery on a retained watch,
160
+ * so an application knows the exact moment its state is complete.
161
+ *
162
+ * `0n` is a definite answer — the key or prefix held nothing at join — not a
163
+ * silence to wait through. `null` on a watch that did not ask for retained
164
+ * delivery.
165
+ */
166
+ readonly retainedCount: bigint | null;
167
+ /** The next item, or `null` once the watch has ended. */
168
+ recv(): Promise<CacheWatchItem | null>;
169
+ close(): Promise<void>;
170
+ readonly closed: boolean;
171
+ [Symbol.asyncDispose](): Promise<void>;
172
+ }
173
+
174
+ /** A connected Felix client. */
175
+ export declare class Client {
176
+ /**
177
+ * Connect to a cluster.
178
+ *
179
+ * Any reachable seed address is enough; the client discovers the rest.
180
+ * TLS is not optional — QUIC has no unencrypted mode. Pass `caFile` to trust
181
+ * a specific CA (what a self-signed development broker needs), or omit it to
182
+ * use the operating system's trust store.
183
+ */
184
+ static connect(
185
+ addrs: string | string[],
186
+ tenantId: string,
187
+ token: string,
188
+ serverName?: string,
189
+ caFile?: string,
190
+ ): Promise<Client>;
191
+
192
+ /**
193
+ * Publish one record.
194
+ *
195
+ * `key` is the routing key, and it decides the shard. Without one every
196
+ * record lands on shard 0, so a multi-shard stream behaves like a
197
+ * single-shard one. Records sharing a key stay ordered with respect to each
198
+ * other; records with different keys do not.
199
+ *
200
+ * `atLeastOnce` **may duplicate the record.** By default a publish that
201
+ * fails after the broker may already have written it is reported, not
202
+ * re-sent, because nothing downstream can tell the copies apart. With
203
+ * `atLeastOnce` it is re-sent to another broker instead: the record is then
204
+ * certain to land, and may land twice. That is a delivery guarantee you
205
+ * choose, never one this client assumes — and it cannot be combined with
206
+ * `key`, which the re-send path does not yet carry.
207
+ */
208
+ publish(
209
+ tenantId: string,
210
+ namespace: string,
211
+ stream: string,
212
+ payload: Buffer,
213
+ key?: Buffer,
214
+ ack?: AckMode,
215
+ atLeastOnce?: boolean,
216
+ ): Promise<void>;
217
+
218
+ /**
219
+ * Subscribe to a stream.
220
+ *
221
+ * `start` is the first record you have *not* seen, so a resuming client
222
+ * passes the offset it last handled plus one.
223
+ *
224
+ * A subscription reads **one shard**. For a multi-shard stream that is shard
225
+ * 0; `subscribeSharded` is what reads the whole stream.
226
+ */
227
+ subscribe(
228
+ tenantId: string,
229
+ namespace: string,
230
+ stream: string,
231
+ start?: StartPosition,
232
+ ): Promise<SubscriptionHandle>;
233
+
234
+ /**
235
+ * Subscribe to **every** shard of a stream and merge them.
236
+ *
237
+ * Per-shard ordering only — that is all a sharded stream has. A lost shard
238
+ * does not disturb the others, and resumes from its own offset.
239
+ *
240
+ * `resume` is a `positions()` result. Offsets are per shard, so resuming is
241
+ * a map rather than a number: one number carried across shards replays on
242
+ * all but one of them.
243
+ */
244
+ subscribeSharded(
245
+ tenantId: string,
246
+ namespace: string,
247
+ stream: string,
248
+ start?: StartPosition,
249
+ resume?: ShardPositions,
250
+ ): Promise<ShardedSubscriptionHandle>;
251
+
252
+ /**
253
+ * How many shards a stream was placed with.
254
+ *
255
+ * `0` means the broker knows nothing of the stream, which is deliberately
256
+ * not `1`: told "one shard" for a stream that does not exist, a consumer
257
+ * would read shard 0, report success, and find out later as missing data.
258
+ */
259
+ streamShards(tenantId: string, namespace: string, stream: string): Promise<number>;
260
+
261
+ /** Every broker this client would try, seeds included. */
262
+ endpoints(): Promise<string[]>;
263
+
264
+ /** Store a value, optionally with a time-to-live in seconds. */
265
+ cachePut(
266
+ tenantId: string,
267
+ namespace: string,
268
+ cache: string,
269
+ key: string,
270
+ value: Buffer,
271
+ ttlSeconds?: number,
272
+ ): Promise<void>;
273
+
274
+ /** Read a value, or `null` if the key is absent or expired. */
275
+ cacheGet(
276
+ tenantId: string,
277
+ namespace: string,
278
+ cache: string,
279
+ key: string,
280
+ ): Promise<Buffer | null>;
281
+
282
+ /**
283
+ * Remove a key, returning what it held, or `null` if it held nothing.
284
+ *
285
+ * The previous value is the return rather than a discard: it is what lets a
286
+ * caller tell "I deleted something" from "it was already gone" without a
287
+ * second round trip.
288
+ */
289
+ cacheDelete(
290
+ tenantId: string,
291
+ namespace: string,
292
+ cache: string,
293
+ key: string,
294
+ ): Promise<Buffer | null>;
295
+
296
+ /** Add to a counter and return its new value. `delta` may be negative. */
297
+ counterAdd(
298
+ tenantId: string,
299
+ namespace: string,
300
+ cache: string,
301
+ key: string,
302
+ delta: number,
303
+ ): Promise<number>;
304
+
305
+ /**
306
+ * Read a counter's current value, or `null` if it does not exist.
307
+ *
308
+ * Absent is not zero: a counter nobody has added to has never been written,
309
+ * and the distinction is the caller's to make.
310
+ */
311
+ counterGet(
312
+ tenantId: string,
313
+ namespace: string,
314
+ cache: string,
315
+ key: string,
316
+ ): Promise<number | null>;
317
+
318
+ /**
319
+ * Watch one cache for changes.
320
+ *
321
+ * Exactly one of `key` or `prefix` selects what to watch; a `prefix` of `""`
322
+ * is every key in the shard.
323
+ *
324
+ * `start` is the first cache-log offset you have *not* seen, so a resuming
325
+ * watcher passes the offset it last handled plus one. `retained` instead
326
+ * delivers each matching key's current value first and then live changes —
327
+ * join a room and immediately hold the roster. The two are mutually
328
+ * exclusive, and asking for both is refused rather than resolved.
329
+ */
330
+ watchCache(
331
+ tenantId: string,
332
+ namespace: string,
333
+ cache: string,
334
+ key?: string,
335
+ prefix?: string,
336
+ start?: bigint,
337
+ retained?: boolean,
338
+ ): Promise<CacheWatchHandle>;
339
+
340
+ /**
341
+ * Claim up to `maxRecords` from a consumer group, waiting up to `waitMs` for
342
+ * one to appear.
343
+ *
344
+ * Each record stays claimed until acked or the visibility timeout lapses, at
345
+ * which point it is handed to someone else — which is why `attempts` is
346
+ * worth reading. An empty array is an answer, not a failure: the group is
347
+ * owed nothing right now.
348
+ */
349
+ groupPoll(
350
+ tenantId: string,
351
+ namespace: string,
352
+ stream: string,
353
+ shard: number,
354
+ group: string,
355
+ maxRecords?: number,
356
+ waitMs?: number,
357
+ ): Promise<GroupRecord[]>;
358
+
359
+ /** Finish a record: it will not be handed out again. */
360
+ groupAck(
361
+ tenantId: string,
362
+ namespace: string,
363
+ stream: string,
364
+ shard: number,
365
+ group: string,
366
+ offset: bigint,
367
+ ): Promise<void>;
368
+
369
+ /** Return a record for redelivery without waiting out its timeout. */
370
+ groupNack(
371
+ tenantId: string,
372
+ namespace: string,
373
+ stream: string,
374
+ shard: number,
375
+ group: string,
376
+ offset: bigint,
377
+ ): Promise<void>;
378
+
379
+ /** Offsets this group gave up on after exhausting their attempts. */
380
+ groupDeadLetters(
381
+ tenantId: string,
382
+ namespace: string,
383
+ stream: string,
384
+ shard: number,
385
+ group: string,
386
+ ): Promise<bigint[]>;
387
+
388
+ /** Drop a dead-lettered record permanently. */
389
+ groupDiscard(
390
+ tenantId: string,
391
+ namespace: string,
392
+ stream: string,
393
+ shard: number,
394
+ group: string,
395
+ offset: bigint,
396
+ ): Promise<void>;
397
+
398
+ /** Put a dead-lettered record back into the group for another attempt. */
399
+ groupRedrive(
400
+ tenantId: string,
401
+ namespace: string,
402
+ stream: string,
403
+ shard: number,
404
+ group: string,
405
+ offset: bigint,
406
+ ): Promise<void>;
407
+
408
+ /**
409
+ * Release the client. Idempotent.
410
+ *
411
+ * Later calls fail rather than quietly using a connection that was meant to
412
+ * be gone. Subscriptions already handed out hold their own connection and
413
+ * keep running — closing the client is not a way to stop them, and `close`
414
+ * on the subscription is.
415
+ */
416
+ close(): void;
417
+ readonly closed: boolean;
418
+ [Symbol.asyncDispose](): Promise<void>;
419
+ }
420
+
421
+ /** The unwrapped addon, for anyone who wants it. Errors are untyped there. */
422
+ export declare const native: unknown;
package/index.js ADDED
@@ -0,0 +1,252 @@
1
+ // The package's JavaScript half: load the addon, and give its errors an
2
+ // identity a caller can branch on.
3
+ //
4
+ // The native layer cannot set `err.code` itself — napi puts an error's status
5
+ // there and `#[napi]` requires that status to be napi's own fixed enum. So it
6
+ // prefixes the message with a Felix code and this file lifts it onto a typed
7
+ // error. Both halves ship as one package, so that prefix is an internal detail
8
+ // rather than something a caller parses.
9
+ //
10
+ // The class hierarchy mirrors the Python binding's exceptions, because the
11
+ // distinction is the same one in both languages: what an application can
12
+ // *decide* from a failure. A connection failure is worth another attempt
13
+ // against another broker; an authorization failure, a missing stream, or a
14
+ // discarded offset will fail the same way every time.
15
+
16
+ "use strict";
17
+
18
+ const { existsSync } = require("node:fs");
19
+ const { join } = require("node:path");
20
+
21
+ /** Base class for every error this client raises. */
22
+ class FelixError extends Error {
23
+ constructor(code, message) {
24
+ super(message);
25
+ this.name = new.target.name;
26
+ /**
27
+ * A stable identifier for *why* this failed. Branch on this, or on the
28
+ * class, rather than on the message — the message is prose and will be
29
+ * reworded.
30
+ */
31
+ this.code = code;
32
+ }
33
+
34
+ /**
35
+ * Whether retrying could plausibly succeed.
36
+ *
37
+ * Only `ConnectionError` says yes. An authorization failure, a missing
38
+ * stream, or a discarded offset fails the same way every time — retrying
39
+ * them burns a budget on a call that cannot succeed.
40
+ */
41
+ get retryable() {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ /** The broker could not be reached, or the connection was lost mid-call. */
47
+ class ConnectionError extends FelixError {
48
+ get retryable() {
49
+ return true;
50
+ }
51
+ }
52
+
53
+ /** The token was rejected, or lacks the permission this call needs. */
54
+ class AuthError extends FelixError {}
55
+
56
+ /** The tenant, namespace, stream or cache does not exist on the broker. */
57
+ class NotFoundError extends FelixError {}
58
+
59
+ /** The requested start offset is gone — retention discarded it. */
60
+ class CursorError extends FelixError {}
61
+
62
+ /** A bad argument to this client, rather than a failure of the call. */
63
+ class InvalidArgumentError extends FelixError {}
64
+
65
+ const CLASSES = new Map([
66
+ ["FELIX_CONNECTION", ConnectionError],
67
+ ["FELIX_AUTH", AuthError],
68
+ ["FELIX_NOT_FOUND", NotFoundError],
69
+ ["FELIX_CURSOR", CursorError],
70
+ ["FELIX_INVALID", InvalidArgumentError],
71
+ ["FELIX_ERROR", FelixError],
72
+ ]);
73
+
74
+ /** Lift a native error into a typed one, leaving anything else alone. */
75
+ function typed(err) {
76
+ const message = err && typeof err.message === "string" ? err.message : "";
77
+ const at = message.indexOf(": ");
78
+ if (at > 0) {
79
+ const Class = CLASSES.get(message.slice(0, at));
80
+ if (Class) {
81
+ const out = new Class(message.slice(0, at), message.slice(at + 2));
82
+ // Keep the native stack: it names the call that failed.
83
+ if (err.stack) out.stack = err.stack.replace(message, out.message);
84
+ return out;
85
+ }
86
+ }
87
+ return err;
88
+ }
89
+
90
+ /**
91
+ * What this machine's binary is called, in napi's naming.
92
+ *
93
+ * It names both the platform package (`felix-client-linux-x64-gnu`) and the
94
+ * file inside it (`felix.linux-x64-gnu.node`), so the two cannot drift.
95
+ */
96
+ function platformTag() {
97
+ const { platform, arch } = process;
98
+ if (platform === "linux") {
99
+ // A glibc build will not load on musl. `glibcVersionRuntime` is absent on
100
+ // musl, which is the only reliable check from inside Node — reading
101
+ // `process.report` costs nothing and is not gated on a flag.
102
+ let libc = "musl";
103
+ try {
104
+ if (process.report?.getReport()?.header?.glibcVersionRuntime) libc = "gnu";
105
+ } catch {
106
+ // A locked-down runtime can refuse the report. Assume glibc, which is
107
+ // what is published; the load below fails with a clear message if wrong.
108
+ libc = "gnu";
109
+ }
110
+ return `linux-${arch}-${libc}`;
111
+ }
112
+ if (platform === "win32") return `win32-${arch}-msvc`;
113
+ return `${platform}-${arch}`;
114
+ }
115
+
116
+ function loadAddon() {
117
+ const tag = platformTag();
118
+
119
+ // The repository shares one target directory across every crate, this one
120
+ // included (`.cargo/config.toml`), so a development build lands at the root
121
+ // rather than beside this file.
122
+ const roots = [join(__dirname, "target"), join(__dirname, "..", "..", "target")];
123
+ const names = [
124
+ "libfelix_typescript.dylib",
125
+ "libfelix_typescript.so",
126
+ "felix_typescript.dll",
127
+ ];
128
+ // `napi build --platform` writes the tagged name; a plain `napi build` the
129
+ // bare one. Both are checked before the installed package, so a local
130
+ // rebuild wins over whatever npm put in node_modules.
131
+ const candidates = [
132
+ join(__dirname, `felix.${tag}.node`),
133
+ join(__dirname, "felix.node"),
134
+ ];
135
+ // A plain `cargo build` is enough to use this package, which is what keeps it
136
+ // usable without the napi CLI — `napi build` is mostly a rename.
137
+ for (const profile of ["release", "debug"]) {
138
+ for (const root of roots) {
139
+ for (const name of names) candidates.push(join(root, profile, name));
140
+ }
141
+ }
142
+ for (const path of candidates) {
143
+ if (!existsSync(path)) continue;
144
+ if (path.endsWith(".node")) return require(path);
145
+ // Node only `require`s files named `.node`, but `process.dlopen` — which
146
+ // is what `require` calls underneath — takes any path. That is what lets a
147
+ // plain `cargo build` be enough, with no rename step.
148
+ const shim = { exports: {} };
149
+ process.dlopen(shim, path);
150
+ return shim.exports;
151
+ }
152
+
153
+ // An installed package has none of the above: npm ships one package per
154
+ // platform and this package declares them all as optional dependencies, so
155
+ // exactly the matching one is present.
156
+ const pkg = `felix-client-${tag}`;
157
+ try {
158
+ return require(pkg);
159
+ } catch (err) {
160
+ // MODULE_NOT_FOUND here means this platform has no published binary, which
161
+ // is worth saying plainly — the alternative is a stack trace about a
162
+ // package the caller never named.
163
+ if (err?.code !== "MODULE_NOT_FOUND") throw err;
164
+ }
165
+
166
+ throw new Error(
167
+ `felix-client: no native addon for ${tag}. Either this platform has no ` +
168
+ `published binary, or the optional dependency ${pkg} did not install. ` +
169
+ `From a checkout, build it with \`napi build --release\` or ` +
170
+ `\`cargo build --release\` in crates/felix-typescript.`,
171
+ );
172
+ }
173
+
174
+ const native = loadAddon();
175
+
176
+ /**
177
+ * Whether `value` is one of the addon's own classes.
178
+ *
179
+ * Only those get wrapped. Everything else a method resolves with — a Buffer, an
180
+ * array of records, a plain object — is the caller's data, and a Proxy around
181
+ * it is not that data: `deepStrictEqual` sees through to the handler,
182
+ * `Buffer.concat` and other brand checks can reject it, and the caller has no
183
+ * way to unwrap. Wrapping only the handles keeps the typed-error layer on the
184
+ * calls, where it belongs, and leaves the payloads alone.
185
+ */
186
+ function isHandle(value) {
187
+ const constructor = value?.constructor;
188
+ return typeof constructor === "function" && native[constructor.name] === constructor;
189
+ }
190
+
191
+ /**
192
+ * Wrap a native handle so every rejection arrives typed.
193
+ *
194
+ * A Proxy rather than patching the prototype: napi defines its methods
195
+ * non-configurable, so `defineProperty` refuses. Proxying also means a method
196
+ * added to the Rust side is covered without being listed here — nothing to
197
+ * forget.
198
+ *
199
+ * `Symbol.asyncDispose` is added on top so `await using` releases a handle on
200
+ * every path out of a block, including a throw. It resolves to `close` when
201
+ * the instance has one, and to nothing when it does not.
202
+ */
203
+ function wrap(value) {
204
+ if (!isHandle(value)) return value;
205
+ return new Proxy(value, {
206
+ get(target, prop) {
207
+ if (prop === Symbol.asyncDispose) {
208
+ if (typeof target.close !== "function") return undefined;
209
+ return async function dispose() {
210
+ await target.close();
211
+ };
212
+ }
213
+ const member = Reflect.get(target, prop, target);
214
+ if (typeof member !== "function") return member;
215
+ return function called(...args) {
216
+ try {
217
+ const out = member.apply(target, args);
218
+ if (out && typeof out.then === "function") {
219
+ return out.then(wrap, (err) => Promise.reject(typed(err)));
220
+ }
221
+ return wrap(out);
222
+ } catch (err) {
223
+ throw typed(err);
224
+ }
225
+ };
226
+ },
227
+ });
228
+ }
229
+
230
+ /** The public `Client`: a façade over the native one that types its errors. */
231
+ const Client = {
232
+ async connect(addrs, tenantId, token, serverName, caFile) {
233
+ try {
234
+ const client = await native.Client.connect(addrs, tenantId, token, serverName, caFile);
235
+ return wrap(client);
236
+ } catch (err) {
237
+ throw typed(err);
238
+ }
239
+ },
240
+ };
241
+
242
+ module.exports = {
243
+ Client,
244
+ FelixError,
245
+ ConnectionError,
246
+ AuthError,
247
+ NotFoundError,
248
+ CursorError,
249
+ InvalidArgumentError,
250
+ /** The unwrapped addon, for anyone who wants it. Errors are untyped there. */
251
+ native,
252
+ };
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "felix-client",
3
+ "version": "0.5.0",
4
+ "description": "Node.js/TypeScript bindings for the Felix client, over the Rust client rather than a reimplementation of it",
5
+ "keywords": [
6
+ "felix",
7
+ "quic",
8
+ "pubsub",
9
+ "streaming",
10
+ "cache",
11
+ "queue",
12
+ "napi",
13
+ "bindings"
14
+ ],
15
+ "license": "Apache-2.0",
16
+ "author": "Gabriel Loewen",
17
+ "homepage": "https://gabloe.github.io/felix/clients/typescript/",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "https://github.com/gabloe/felix.git",
21
+ "directory": "crates/felix-typescript"
22
+ },
23
+ "bugs": {
24
+ "url": "https://github.com/gabloe/felix/issues"
25
+ },
26
+ "main": "index.js",
27
+ "types": "index.d.ts",
28
+ "files": [
29
+ "index.js",
30
+ "index.d.ts",
31
+ "README.md",
32
+ "LICENSE"
33
+ ],
34
+ "engines": {
35
+ "node": ">=18"
36
+ },
37
+ "napi": {
38
+ "name": "felix",
39
+ "triples": {
40
+ "defaults": false,
41
+ "additional": [
42
+ "aarch64-apple-darwin",
43
+ "aarch64-unknown-linux-gnu",
44
+ "x86_64-apple-darwin",
45
+ "x86_64-pc-windows-msvc",
46
+ "x86_64-unknown-linux-gnu"
47
+ ]
48
+ }
49
+ },
50
+ "scripts": {
51
+ "build": "napi build --platform --release",
52
+ "build:debug": "napi build --platform",
53
+ "test": "node --test test/conformance.test.mjs"
54
+ },
55
+ "optionalDependencies": {
56
+ "felix-client-darwin-arm64": "0.5.0",
57
+ "felix-client-darwin-x64": "0.5.0",
58
+ "felix-client-linux-arm64-gnu": "0.5.0",
59
+ "felix-client-linux-x64-gnu": "0.5.0",
60
+ "felix-client-win32-x64-msvc": "0.5.0"
61
+ }
62
+ }