felix-gateway-client 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +26 -0
- package/dist/fake.d.ts +58 -0
- package/dist/fake.js +145 -0
- package/dist/index.d.ts +159 -0
- package/dist/index.js +201 -0
- package/package.json +41 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Gabriel Loewen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# felix-gateway-client
|
|
2
|
+
|
|
3
|
+
The browser client for [felix-gateway](https://github.com/GetFelix/felix-gateway). It opens a WebSocket, joins one
|
|
4
|
+
scope (a room or a match) with an ID token, and then publishes, subscribes,
|
|
5
|
+
reads and watches caches and adds to counters, each named by its alias in the
|
|
6
|
+
gateway's scope file.
|
|
7
|
+
[docs/protocol.md](https://github.com/GetFelix/felix-gateway/blob/main/docs/protocol.md)
|
|
8
|
+
describes the protocol.
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import { GatewayClient } from "felix-gateway-client";
|
|
12
|
+
|
|
13
|
+
const client = await GatewayClient.connect("wss://example.com/ws", { room: "lobby", token });
|
|
14
|
+
client.onEvent = (event) => console.log(event.stream, event.offset, event.payload);
|
|
15
|
+
client.subscribe("ops", "live");
|
|
16
|
+
await client.publish("ops", new TextEncoder().encode("hello"));
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`felix-gateway-client/fake` has `FakeScope` and `FakeGateway`, an in-memory
|
|
20
|
+
stand-in with the same shape as `GatewayClient`, for tests that should not need
|
|
21
|
+
a gateway or a broker. It can drop live records and lose acks to exercise
|
|
22
|
+
recovery paths.
|
|
23
|
+
|
|
24
|
+
## License
|
|
25
|
+
|
|
26
|
+
MIT
|
package/dist/fake.d.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { type CacheEntry, type Gateway } from "./index.js";
|
|
2
|
+
/**
|
|
3
|
+
* The streams, caches and counters of one scope, shared by every
|
|
4
|
+
* {@link FakeGateway} connected to it.
|
|
5
|
+
*/
|
|
6
|
+
export declare class FakeScope {
|
|
7
|
+
/** Each stream's records, indexed by log offset. */
|
|
8
|
+
readonly streams: Map<string, Uint8Array<ArrayBufferLike>[]>;
|
|
9
|
+
/** Each cache's entries by key. */
|
|
10
|
+
readonly caches: Map<string, Map<string, Uint8Array<ArrayBufferLike>>>;
|
|
11
|
+
/** Each counter's sums by key. */
|
|
12
|
+
readonly counters: Map<string, Map<string, number>>;
|
|
13
|
+
/** The first offset each stream still holds; a subscribe from earlier is `trimmed`. */
|
|
14
|
+
readonly retainedFrom: Map<string, number>;
|
|
15
|
+
readonly connections: Set<FakeGateway>;
|
|
16
|
+
/** The records of `stream`, which a test may read or fill directly. */
|
|
17
|
+
log(stream: string): Uint8Array[];
|
|
18
|
+
/** Append a record to `stream`, deliver it to subscribers and return its offset. */
|
|
19
|
+
append(stream: string, payload: Uint8Array): number;
|
|
20
|
+
/** Answer a `cacheGet`. Override it to compute a value when it is read. */
|
|
21
|
+
readCache(cache: string, key: string): Uint8Array | null;
|
|
22
|
+
/** Write `key` of `cache`, or delete it with `null`, and tell its watchers. */
|
|
23
|
+
writeCache(cache: string, key: string, payload: Uint8Array | null): void;
|
|
24
|
+
/** Add `delta` to `key` of `counter` and return the new sum. */
|
|
25
|
+
addCounter(counter: string, key: string, delta: number): number;
|
|
26
|
+
}
|
|
27
|
+
/** One connection to a {@link FakeScope}, shaped like a `GatewayClient`. */
|
|
28
|
+
export declare class FakeGateway implements Gateway {
|
|
29
|
+
#private;
|
|
30
|
+
readonly scope: FakeScope;
|
|
31
|
+
onHello: Gateway["onHello"];
|
|
32
|
+
onEvent: Gateway["onEvent"];
|
|
33
|
+
onSubscribed: Gateway["onSubscribed"];
|
|
34
|
+
onError: Gateway["onError"];
|
|
35
|
+
onClose: Gateway["onClose"];
|
|
36
|
+
onCacheEntries: Gateway["onCacheEntries"];
|
|
37
|
+
onCacheChange: Gateway["onCacheChange"];
|
|
38
|
+
/** While set, live records are lost on the way, as Felix drops them for a slow reader. */
|
|
39
|
+
dropping: boolean;
|
|
40
|
+
/** While set, a publish lands in the log but its answer is lost, as when the owner fails. */
|
|
41
|
+
losingAcks: boolean;
|
|
42
|
+
/** The last rate passed to {@link throttle}. */
|
|
43
|
+
bitsPerSecond: number | null;
|
|
44
|
+
constructor(scope: FakeScope);
|
|
45
|
+
subscribe(stream: string, from: number | "live"): void;
|
|
46
|
+
/** Deliver the record at `offset` of `stream` if this connection subscribes to it. */
|
|
47
|
+
deliver(stream: string, offset: number, live?: boolean): void;
|
|
48
|
+
publish(stream: string, payload: Uint8Array, ack?: boolean): Promise<number | null>;
|
|
49
|
+
counterAdd(counter: string, key: string, delta: number): Promise<number>;
|
|
50
|
+
cacheGet(cache: string, key: string): Promise<Uint8Array | null>;
|
|
51
|
+
cachePut(cache: string, key: string, payload: Uint8Array): void;
|
|
52
|
+
cacheDelete(cache: string, key: string): void;
|
|
53
|
+
cacheWatch(cache: string): void;
|
|
54
|
+
/** Report a change to `cache` if this connection watches it. */
|
|
55
|
+
changed(cache: string, entry: CacheEntry): void;
|
|
56
|
+
throttle(bitsPerSecond: number | null): void;
|
|
57
|
+
close(): void;
|
|
58
|
+
}
|
package/dist/fake.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// An in-memory stand-in for the gateway, for testing code written against
|
|
2
|
+
// GatewayClient without a gateway or a broker. Everything is answered on a
|
|
3
|
+
// later task, as a socket would.
|
|
4
|
+
import { GatewayError } from "./index.js";
|
|
5
|
+
/**
|
|
6
|
+
* The streams, caches and counters of one scope, shared by every
|
|
7
|
+
* {@link FakeGateway} connected to it.
|
|
8
|
+
*/
|
|
9
|
+
export class FakeScope {
|
|
10
|
+
/** Each stream's records, indexed by log offset. */
|
|
11
|
+
streams = new Map();
|
|
12
|
+
/** Each cache's entries by key. */
|
|
13
|
+
caches = new Map();
|
|
14
|
+
/** Each counter's sums by key. */
|
|
15
|
+
counters = new Map();
|
|
16
|
+
/** The first offset each stream still holds; a subscribe from earlier is `trimmed`. */
|
|
17
|
+
retainedFrom = new Map();
|
|
18
|
+
connections = new Set();
|
|
19
|
+
/** The records of `stream`, which a test may read or fill directly. */
|
|
20
|
+
log(stream) {
|
|
21
|
+
let log = this.streams.get(stream);
|
|
22
|
+
if (!log)
|
|
23
|
+
this.streams.set(stream, (log = []));
|
|
24
|
+
return log;
|
|
25
|
+
}
|
|
26
|
+
/** Append a record to `stream`, deliver it to subscribers and return its offset. */
|
|
27
|
+
append(stream, payload) {
|
|
28
|
+
const offset = this.log(stream).push(payload) - 1;
|
|
29
|
+
for (const connection of this.connections)
|
|
30
|
+
connection.deliver(stream, offset);
|
|
31
|
+
return offset;
|
|
32
|
+
}
|
|
33
|
+
/** Answer a `cacheGet`. Override it to compute a value when it is read. */
|
|
34
|
+
readCache(cache, key) {
|
|
35
|
+
return this.caches.get(cache)?.get(key) ?? null;
|
|
36
|
+
}
|
|
37
|
+
/** Write `key` of `cache`, or delete it with `null`, and tell its watchers. */
|
|
38
|
+
writeCache(cache, key, payload) {
|
|
39
|
+
let entries = this.caches.get(cache);
|
|
40
|
+
if (!entries)
|
|
41
|
+
this.caches.set(cache, (entries = new Map()));
|
|
42
|
+
if (payload === null)
|
|
43
|
+
entries.delete(key);
|
|
44
|
+
else
|
|
45
|
+
entries.set(key, payload);
|
|
46
|
+
for (const connection of this.connections) {
|
|
47
|
+
connection.changed(cache, { key, payload, expiresInMs: null });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Add `delta` to `key` of `counter` and return the new sum. */
|
|
51
|
+
addCounter(counter, key, delta) {
|
|
52
|
+
let sums = this.counters.get(counter);
|
|
53
|
+
if (!sums)
|
|
54
|
+
this.counters.set(counter, (sums = new Map()));
|
|
55
|
+
const value = (sums.get(key) ?? 0) + delta;
|
|
56
|
+
sums.set(key, value);
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** One connection to a {@link FakeScope}, shaped like a `GatewayClient`. */
|
|
61
|
+
export class FakeGateway {
|
|
62
|
+
scope;
|
|
63
|
+
onHello = () => { };
|
|
64
|
+
onEvent = () => { };
|
|
65
|
+
onSubscribed = () => { };
|
|
66
|
+
onError = () => { };
|
|
67
|
+
onClose = () => { };
|
|
68
|
+
onCacheEntries = () => { };
|
|
69
|
+
onCacheChange = () => { };
|
|
70
|
+
/** While set, live records are lost on the way, as Felix drops them for a slow reader. */
|
|
71
|
+
dropping = false;
|
|
72
|
+
/** While set, a publish lands in the log but its answer is lost, as when the owner fails. */
|
|
73
|
+
losingAcks = false;
|
|
74
|
+
/** The last rate passed to {@link throttle}. */
|
|
75
|
+
bitsPerSecond = null;
|
|
76
|
+
#from = new Map();
|
|
77
|
+
#watching = new Set();
|
|
78
|
+
constructor(scope) {
|
|
79
|
+
this.scope = scope;
|
|
80
|
+
scope.connections.add(this);
|
|
81
|
+
}
|
|
82
|
+
subscribe(stream, from) {
|
|
83
|
+
setTimeout(() => {
|
|
84
|
+
const oldest = this.scope.retainedFrom.get(stream) ?? 0;
|
|
85
|
+
if (typeof from === "number" && from < oldest) {
|
|
86
|
+
this.onError(new GatewayError("trimmed", `oldest is ${oldest}`), stream);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const tail = this.scope.log(stream).length;
|
|
90
|
+
const start = from === "live" ? tail : from;
|
|
91
|
+
this.#from.set(stream, start);
|
|
92
|
+
this.onSubscribed(stream, start, tail);
|
|
93
|
+
for (let offset = start; offset < tail; offset++)
|
|
94
|
+
this.deliver(stream, offset, false);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
/** Deliver the record at `offset` of `stream` if this connection subscribes to it. */
|
|
98
|
+
deliver(stream, offset, live = true) {
|
|
99
|
+
const from = this.#from.get(stream);
|
|
100
|
+
if (from === undefined || offset < from || (live && this.dropping))
|
|
101
|
+
return;
|
|
102
|
+
const payload = this.scope.log(stream)[offset];
|
|
103
|
+
this.onEvent({ stream, offset, skippedBefore: 0, payload });
|
|
104
|
+
}
|
|
105
|
+
async publish(stream, payload, ack = true) {
|
|
106
|
+
const offset = this.scope.append(stream, payload);
|
|
107
|
+
if (this.losingAcks)
|
|
108
|
+
throw new GatewayError("publish_failed", "connection lost");
|
|
109
|
+
return ack ? offset : null;
|
|
110
|
+
}
|
|
111
|
+
async counterAdd(counter, key, delta) {
|
|
112
|
+
return this.scope.addCounter(counter, key, delta);
|
|
113
|
+
}
|
|
114
|
+
async cacheGet(cache, key) {
|
|
115
|
+
await new Promise((resolve) => setTimeout(resolve));
|
|
116
|
+
return this.scope.readCache(cache, key);
|
|
117
|
+
}
|
|
118
|
+
cachePut(cache, key, payload) {
|
|
119
|
+
this.scope.writeCache(cache, key, payload);
|
|
120
|
+
}
|
|
121
|
+
cacheDelete(cache, key) {
|
|
122
|
+
this.scope.writeCache(cache, key, null);
|
|
123
|
+
}
|
|
124
|
+
cacheWatch(cache) {
|
|
125
|
+
setTimeout(() => {
|
|
126
|
+
this.#watching.add(cache);
|
|
127
|
+
const entries = [...(this.scope.caches.get(cache) ?? [])].map(([key, payload]) => ({ key, payload, expiresInMs: null }));
|
|
128
|
+
this.onCacheEntries(cache, entries);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
/** Report a change to `cache` if this connection watches it. */
|
|
132
|
+
changed(cache, entry) {
|
|
133
|
+
if (this.#watching.has(cache))
|
|
134
|
+
this.onCacheChange(cache, entry);
|
|
135
|
+
}
|
|
136
|
+
throttle(bitsPerSecond) {
|
|
137
|
+
this.bitsPerSecond = bitsPerSecond;
|
|
138
|
+
}
|
|
139
|
+
close() {
|
|
140
|
+
this.#from.clear();
|
|
141
|
+
this.#watching.clear();
|
|
142
|
+
this.scope.connections.delete(this);
|
|
143
|
+
this.onClose();
|
|
144
|
+
}
|
|
145
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/** The protocol version this client speaks. */
|
|
2
|
+
export declare const PROTOCOL = 1;
|
|
3
|
+
/** Messages the gateway sends. */
|
|
4
|
+
export type ServerMessage = {
|
|
5
|
+
type: "hello";
|
|
6
|
+
protocol: number;
|
|
7
|
+
features: string[];
|
|
8
|
+
namespace: string;
|
|
9
|
+
cache_ttl_ms: Record<string, number>;
|
|
10
|
+
missing?: string[];
|
|
11
|
+
} | {
|
|
12
|
+
type: "subscribed";
|
|
13
|
+
stream: string;
|
|
14
|
+
start_offset: number | null;
|
|
15
|
+
live_offset: number | null;
|
|
16
|
+
} | {
|
|
17
|
+
type: "event";
|
|
18
|
+
stream: string;
|
|
19
|
+
offset: number | null;
|
|
20
|
+
skipped_before?: number;
|
|
21
|
+
payload: string;
|
|
22
|
+
} | {
|
|
23
|
+
type: "ack";
|
|
24
|
+
id: number;
|
|
25
|
+
offset: number | null;
|
|
26
|
+
} | {
|
|
27
|
+
type: "counter";
|
|
28
|
+
id: number;
|
|
29
|
+
value: number;
|
|
30
|
+
} | {
|
|
31
|
+
type: "cache_value";
|
|
32
|
+
id: number;
|
|
33
|
+
payload: string | null;
|
|
34
|
+
} | {
|
|
35
|
+
type: "cache_entries";
|
|
36
|
+
cache: string;
|
|
37
|
+
entries: WireEntry[];
|
|
38
|
+
} | ({
|
|
39
|
+
type: "cache_change";
|
|
40
|
+
cache: string;
|
|
41
|
+
} & WireEntry) | {
|
|
42
|
+
type: "error";
|
|
43
|
+
id?: number;
|
|
44
|
+
stream?: string;
|
|
45
|
+
cache?: string;
|
|
46
|
+
code: "bad_request" | "unsupported" | "publish_failed" | "subscribe_failed" | "subscription_ended" | "counter_failed" | "cache_failed" | "trimmed" | "watch_failed" | "signed_out" | "forbidden" | "unavailable";
|
|
47
|
+
oldest?: number;
|
|
48
|
+
message: string;
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* The sign-in, and the scope to open under the key the gateway's scope file
|
|
52
|
+
* names, such as `{ room: "lobby", token }`.
|
|
53
|
+
*/
|
|
54
|
+
export interface Join {
|
|
55
|
+
token: string;
|
|
56
|
+
[scopeField: string]: string;
|
|
57
|
+
}
|
|
58
|
+
/** What the gateway answered a join with. */
|
|
59
|
+
export interface Hello {
|
|
60
|
+
namespace: string;
|
|
61
|
+
/** The features asked for that the gateway accepted. */
|
|
62
|
+
features: string[];
|
|
63
|
+
/** How long an entry lasts without a write, for each cache with a TTL. */
|
|
64
|
+
cacheTtlMs: Record<string, number>;
|
|
65
|
+
/** Optional resources the sign-in does not reach. */
|
|
66
|
+
missing: string[];
|
|
67
|
+
}
|
|
68
|
+
interface WireEntry {
|
|
69
|
+
key: string;
|
|
70
|
+
payload: string | null;
|
|
71
|
+
expires_in_ms: number | null;
|
|
72
|
+
}
|
|
73
|
+
/** One cache entry as the gateway reports it. */
|
|
74
|
+
export interface CacheEntry {
|
|
75
|
+
key: string;
|
|
76
|
+
/** The entry's value, or `null` when it was deleted. */
|
|
77
|
+
payload: Uint8Array | null;
|
|
78
|
+
/** Milliseconds until the entry expires, or `null` if it never does. */
|
|
79
|
+
expiresInMs: number | null;
|
|
80
|
+
}
|
|
81
|
+
/** One record delivered on a stream. */
|
|
82
|
+
export interface GatewayEvent {
|
|
83
|
+
stream: string;
|
|
84
|
+
/** Log offset; `null` on a stream with no log. */
|
|
85
|
+
offset: number | null;
|
|
86
|
+
/** Offsets just before this one that hold no event. */
|
|
87
|
+
skippedBefore: number;
|
|
88
|
+
payload: Uint8Array;
|
|
89
|
+
}
|
|
90
|
+
/** Raised when the gateway reports that a request failed. */
|
|
91
|
+
export declare class GatewayError extends Error {
|
|
92
|
+
readonly code: string;
|
|
93
|
+
name: string;
|
|
94
|
+
constructor(code: string, message: string);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Everything a {@link GatewayClient} offers, without its private state, so
|
|
98
|
+
* code can accept a stand-in such as `FakeGateway` from `felix-gateway-client/fake`.
|
|
99
|
+
*/
|
|
100
|
+
export type Gateway = Pick<GatewayClient, keyof GatewayClient>;
|
|
101
|
+
/**
|
|
102
|
+
* A connection to the gateway. Events and subscription changes are reported
|
|
103
|
+
* through the callbacks; publishes resolve with the record's log offset.
|
|
104
|
+
*/
|
|
105
|
+
export declare class GatewayClient {
|
|
106
|
+
#private;
|
|
107
|
+
/** Called once, first, when the gateway has opened the session. */
|
|
108
|
+
onHello: (hello: Hello) => void;
|
|
109
|
+
/** Called with every entry of a cache when a watch of it starts. */
|
|
110
|
+
onCacheEntries: (cache: string, entries: CacheEntry[]) => void;
|
|
111
|
+
/** Called for each entry written or deleted after that. */
|
|
112
|
+
onCacheChange: (cache: string, entry: CacheEntry) => void;
|
|
113
|
+
/** Called for every event, in the order the broker delivered them. */
|
|
114
|
+
onEvent: (event: GatewayEvent) => void;
|
|
115
|
+
/** Called once a subscription is registered with the broker. */
|
|
116
|
+
onSubscribed: (stream: string, start: number | null, live: number | null) => void;
|
|
117
|
+
/**
|
|
118
|
+
* Called for errors not tied to a request, such as a subscription ending,
|
|
119
|
+
* with the stream or cache they are about.
|
|
120
|
+
*/
|
|
121
|
+
onError: (error: GatewayError, stream?: string, cache?: string) => void;
|
|
122
|
+
/** Called when the connection closes. */
|
|
123
|
+
onClose: () => void;
|
|
124
|
+
private constructor();
|
|
125
|
+
/**
|
|
126
|
+
* Open a connection to the gateway at `url`, a `ws:` or `wss:` URL, and
|
|
127
|
+
* join the scope in `join`, asking for `features`. The gateway answers with
|
|
128
|
+
* `hello`, or refuses with an error and closes.
|
|
129
|
+
*/
|
|
130
|
+
static connect(url: string, join: Join, features?: string[]): Promise<GatewayClient>;
|
|
131
|
+
/**
|
|
132
|
+
* Relay `stream` from `from`: a log offset (the last one handled, plus one)
|
|
133
|
+
* or `"live"`. Replaces an earlier subscription to the same stream.
|
|
134
|
+
*/
|
|
135
|
+
subscribe(stream: string, from: number | "live"): void;
|
|
136
|
+
/**
|
|
137
|
+
* Publish one record. With `ack`, resolves once the broker has it, with its
|
|
138
|
+
* log offset when the broker acknowledged after writing. Without, resolves
|
|
139
|
+
* as soon as the request is sent.
|
|
140
|
+
*/
|
|
141
|
+
publish(stream: string, payload: Uint8Array, ack?: boolean): Promise<number | null>;
|
|
142
|
+
/** Add `delta` to `key` of `counter` and resolve with the new sum. */
|
|
143
|
+
counterAdd(counter: string, key: string, delta: number): Promise<number>;
|
|
144
|
+
/** Read `key` of `cache`: its value, or `null` when there is none. */
|
|
145
|
+
cacheGet(cache: string, key: string): Promise<Uint8Array | null>;
|
|
146
|
+
/** Write `key` of `cache`. It expires after the cache's TTL unless written again. */
|
|
147
|
+
cachePut(cache: string, key: string, payload: Uint8Array): void;
|
|
148
|
+
/** Delete `key` of `cache`. */
|
|
149
|
+
cacheDelete(cache: string, key: string): void;
|
|
150
|
+
/** Watch the entries of `cache`. Replaces an earlier watch of it. */
|
|
151
|
+
cacheWatch(cache: string): void;
|
|
152
|
+
/**
|
|
153
|
+
* Have the gateway read this connection's subscriptions no faster than a
|
|
154
|
+
* link of `bitsPerSecond` would carry them, or at full speed with `null`.
|
|
155
|
+
*/
|
|
156
|
+
throttle(bitsPerSecond: number | null): void;
|
|
157
|
+
close(): void;
|
|
158
|
+
}
|
|
159
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// The browser half of the gateway protocol. docs/protocol.md is the reference.
|
|
2
|
+
// Streams, caches and counters are named by their alias in the gateway's
|
|
3
|
+
// scope file.
|
|
4
|
+
/** The protocol version this client speaks. */
|
|
5
|
+
export const PROTOCOL = 1;
|
|
6
|
+
/** Raised when the gateway reports that a request failed. */
|
|
7
|
+
export class GatewayError extends Error {
|
|
8
|
+
code;
|
|
9
|
+
name = "GatewayError";
|
|
10
|
+
constructor(code, message) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.code = code;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* A connection to the gateway. Events and subscription changes are reported
|
|
17
|
+
* through the callbacks; publishes resolve with the record's log offset.
|
|
18
|
+
*/
|
|
19
|
+
export class GatewayClient {
|
|
20
|
+
/** Called once, first, when the gateway has opened the session. */
|
|
21
|
+
onHello = () => { };
|
|
22
|
+
/** Called with every entry of a cache when a watch of it starts. */
|
|
23
|
+
onCacheEntries = () => { };
|
|
24
|
+
/** Called for each entry written or deleted after that. */
|
|
25
|
+
onCacheChange = () => { };
|
|
26
|
+
/** Called for every event, in the order the broker delivered them. */
|
|
27
|
+
onEvent = () => { };
|
|
28
|
+
/** Called once a subscription is registered with the broker. */
|
|
29
|
+
onSubscribed = () => { };
|
|
30
|
+
/**
|
|
31
|
+
* Called for errors not tied to a request, such as a subscription ending,
|
|
32
|
+
* with the stream or cache they are about.
|
|
33
|
+
*/
|
|
34
|
+
onError = () => { };
|
|
35
|
+
/** Called when the connection closes. */
|
|
36
|
+
onClose = () => { };
|
|
37
|
+
#socket;
|
|
38
|
+
#pending = new Map();
|
|
39
|
+
#nextId = 0;
|
|
40
|
+
constructor(socket) {
|
|
41
|
+
this.#socket = socket;
|
|
42
|
+
socket.addEventListener("message", (message) => this.#receive(String(message.data)));
|
|
43
|
+
socket.addEventListener("close", () => {
|
|
44
|
+
for (const pending of this.#pending.values()) {
|
|
45
|
+
pending.reject(new GatewayError("closed", "the gateway connection closed"));
|
|
46
|
+
}
|
|
47
|
+
this.#pending.clear();
|
|
48
|
+
this.onClose();
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Open a connection to the gateway at `url`, a `ws:` or `wss:` URL, and
|
|
53
|
+
* join the scope in `join`, asking for `features`. The gateway answers with
|
|
54
|
+
* `hello`, or refuses with an error and closes.
|
|
55
|
+
*/
|
|
56
|
+
static connect(url, join, features = []) {
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
const socket = new WebSocket(url);
|
|
59
|
+
socket.addEventListener("open", () => {
|
|
60
|
+
const client = new GatewayClient(socket);
|
|
61
|
+
client.#send({ ...join, type: "join", protocol: PROTOCOL, features });
|
|
62
|
+
resolve(client);
|
|
63
|
+
}, { once: true });
|
|
64
|
+
socket.addEventListener("error", () => reject(new Error(`cannot reach ${url}`)), {
|
|
65
|
+
once: true,
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Relay `stream` from `from`: a log offset (the last one handled, plus one)
|
|
71
|
+
* or `"live"`. Replaces an earlier subscription to the same stream.
|
|
72
|
+
*/
|
|
73
|
+
subscribe(stream, from) {
|
|
74
|
+
this.#send({ type: "subscribe", stream, from });
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Publish one record. With `ack`, resolves once the broker has it, with its
|
|
78
|
+
* log offset when the broker acknowledged after writing. Without, resolves
|
|
79
|
+
* as soon as the request is sent.
|
|
80
|
+
*/
|
|
81
|
+
publish(stream, payload, ack = true) {
|
|
82
|
+
const message = { type: "publish", stream, payload: toBase64(payload), ack };
|
|
83
|
+
if (!ack) {
|
|
84
|
+
this.#send({ ...message, id: this.#nextId++ });
|
|
85
|
+
return Promise.resolve(null);
|
|
86
|
+
}
|
|
87
|
+
return this.#request(message);
|
|
88
|
+
}
|
|
89
|
+
/** Add `delta` to `key` of `counter` and resolve with the new sum. */
|
|
90
|
+
counterAdd(counter, key, delta) {
|
|
91
|
+
return this.#request({ type: "counter_add", counter, key, delta });
|
|
92
|
+
}
|
|
93
|
+
/** Read `key` of `cache`: its value, or `null` when there is none. */
|
|
94
|
+
async cacheGet(cache, key) {
|
|
95
|
+
const payload = (await this.#request({ type: "cache_get", cache, key }));
|
|
96
|
+
return payload === null ? null : fromBase64(payload);
|
|
97
|
+
}
|
|
98
|
+
/** Write `key` of `cache`. It expires after the cache's TTL unless written again. */
|
|
99
|
+
cachePut(cache, key, payload) {
|
|
100
|
+
this.#send({ type: "cache_put", cache, key, payload: toBase64(payload) });
|
|
101
|
+
}
|
|
102
|
+
/** Delete `key` of `cache`. */
|
|
103
|
+
cacheDelete(cache, key) {
|
|
104
|
+
this.#send({ type: "cache_delete", cache, key });
|
|
105
|
+
}
|
|
106
|
+
/** Watch the entries of `cache`. Replaces an earlier watch of it. */
|
|
107
|
+
cacheWatch(cache) {
|
|
108
|
+
this.#send({ type: "cache_watch", cache });
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Have the gateway read this connection's subscriptions no faster than a
|
|
112
|
+
* link of `bitsPerSecond` would carry them, or at full speed with `null`.
|
|
113
|
+
*/
|
|
114
|
+
throttle(bitsPerSecond) {
|
|
115
|
+
this.#send({ type: "throttle", bits_per_second: bitsPerSecond });
|
|
116
|
+
}
|
|
117
|
+
close() {
|
|
118
|
+
this.#socket.close();
|
|
119
|
+
}
|
|
120
|
+
#request(message) {
|
|
121
|
+
const id = this.#nextId++;
|
|
122
|
+
this.#send({ ...message, id });
|
|
123
|
+
return new Promise((resolve, reject) => this.#pending.set(id, { resolve, reject }));
|
|
124
|
+
}
|
|
125
|
+
#send(message) {
|
|
126
|
+
this.#socket.send(JSON.stringify(message));
|
|
127
|
+
}
|
|
128
|
+
#receive(text) {
|
|
129
|
+
const message = JSON.parse(text);
|
|
130
|
+
switch (message.type) {
|
|
131
|
+
case "event":
|
|
132
|
+
this.onEvent({
|
|
133
|
+
stream: message.stream,
|
|
134
|
+
offset: message.offset,
|
|
135
|
+
skippedBefore: message.skipped_before ?? 0,
|
|
136
|
+
payload: fromBase64(message.payload),
|
|
137
|
+
});
|
|
138
|
+
break;
|
|
139
|
+
case "hello":
|
|
140
|
+
this.onHello({
|
|
141
|
+
namespace: message.namespace,
|
|
142
|
+
features: message.features,
|
|
143
|
+
cacheTtlMs: message.cache_ttl_ms,
|
|
144
|
+
missing: message.missing ?? [],
|
|
145
|
+
});
|
|
146
|
+
break;
|
|
147
|
+
case "subscribed":
|
|
148
|
+
this.onSubscribed(message.stream, message.start_offset, message.live_offset);
|
|
149
|
+
break;
|
|
150
|
+
case "cache_entries":
|
|
151
|
+
this.onCacheEntries(message.cache, message.entries.map(cacheEntry));
|
|
152
|
+
break;
|
|
153
|
+
case "cache_change":
|
|
154
|
+
this.onCacheChange(message.cache, cacheEntry(message));
|
|
155
|
+
break;
|
|
156
|
+
case "counter":
|
|
157
|
+
this.#take(message.id)?.resolve(message.value);
|
|
158
|
+
break;
|
|
159
|
+
case "ack":
|
|
160
|
+
this.#take(message.id)?.resolve(message.offset);
|
|
161
|
+
break;
|
|
162
|
+
case "cache_value":
|
|
163
|
+
this.#take(message.id)?.resolve(message.payload);
|
|
164
|
+
break;
|
|
165
|
+
case "error": {
|
|
166
|
+
const error = new GatewayError(message.code, message.message);
|
|
167
|
+
const pending = this.#take(message.id);
|
|
168
|
+
if (pending) {
|
|
169
|
+
pending.reject(error);
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
this.onError(error, message.stream, message.cache);
|
|
173
|
+
}
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
#take(id) {
|
|
179
|
+
if (id === undefined)
|
|
180
|
+
return undefined;
|
|
181
|
+
const pending = this.#pending.get(id);
|
|
182
|
+
this.#pending.delete(id);
|
|
183
|
+
return pending;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function cacheEntry(entry) {
|
|
187
|
+
return {
|
|
188
|
+
key: entry.key,
|
|
189
|
+
payload: entry.payload === null ? null : fromBase64(entry.payload),
|
|
190
|
+
expiresInMs: entry.expires_in_ms,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
function toBase64(bytes) {
|
|
194
|
+
let binary = "";
|
|
195
|
+
for (const byte of bytes)
|
|
196
|
+
binary += String.fromCharCode(byte);
|
|
197
|
+
return btoa(binary);
|
|
198
|
+
}
|
|
199
|
+
function fromBase64(text) {
|
|
200
|
+
return Uint8Array.from(atob(text), (char) => char.charCodeAt(0));
|
|
201
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "felix-gateway-client",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The browser client for felix-gateway, and an in-memory fake of it for tests",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/GetFelix/felix-gateway.git",
|
|
9
|
+
"directory": "packages/gateway-client"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/GetFelix/felix-gateway#readme",
|
|
12
|
+
"bugs": "https://github.com/GetFelix/felix-gateway/issues",
|
|
13
|
+
"keywords": [
|
|
14
|
+
"felix",
|
|
15
|
+
"websocket",
|
|
16
|
+
"gateway",
|
|
17
|
+
"pubsub"
|
|
18
|
+
],
|
|
19
|
+
"type": "module",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"default": "./dist/index.js"
|
|
24
|
+
},
|
|
25
|
+
"./fake": {
|
|
26
|
+
"types": "./dist/fake.d.ts",
|
|
27
|
+
"default": "./dist/fake.js"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"dist"
|
|
32
|
+
],
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "tsc -p tsconfig.build.json",
|
|
35
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
36
|
+
"test": "vitest run"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"vitest": "^5.0.2"
|
|
40
|
+
}
|
|
41
|
+
}
|