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.
- package/LICENSE +201 -0
- package/README.md +184 -0
- package/index.d.ts +422 -0
- package/index.js +252 -0
- 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
|
+
}
|