geonix 1.30.2 → 1.31.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/README.md +9 -1
- package/package.json +2 -2
- package/src/Codec.js +1 -1
- package/src/Connection.js +89 -19
- package/src/Crypto.js +23 -9
- package/src/Gateway.js +47 -32
- package/src/LocalBus.js +190 -0
- package/src/Logger.js +16 -7
- package/src/Registry.js +19 -13
- package/src/Remote.js +12 -8
- package/src/Request.js +35 -16
- package/src/Service.js +66 -39
- package/src/Stream.js +12 -6
- package/src/Util.js +70 -56
- package/src/WebServer.js +11 -12
- package/.vscode/settings.json +0 -11
package/src/LocalBus.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
|
|
3
|
+
const LOCAL_MAX_PAYLOAD = 8 * 1024 * 1024;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* NATS-style subject matcher. Tokens split on `.`.
|
|
7
|
+
* `*` matches exactly one token.
|
|
8
|
+
* `>` matches one or more trailing tokens; must be the last token in the pattern.
|
|
9
|
+
*
|
|
10
|
+
* @param {string} pattern
|
|
11
|
+
* @param {string} subject
|
|
12
|
+
* @returns {boolean}
|
|
13
|
+
*/
|
|
14
|
+
export function subjectMatch(pattern, subject) {
|
|
15
|
+
const p = pattern.split(".");
|
|
16
|
+
const s = subject.split(".");
|
|
17
|
+
for (let i = 0; i < p.length; i++) {
|
|
18
|
+
if (p[i] === ">") {
|
|
19
|
+
return i === p.length - 1 && s.length > i;
|
|
20
|
+
}
|
|
21
|
+
if (i >= s.length) {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
if (p[i] !== "*" && p[i] !== s[i]) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return p.length === s.length;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A single subscription on the in-memory bus. Async iterable; yields `{ data, subject }`
|
|
33
|
+
* events in publish order. Auto-closes after `max` deliveries when `max` is set.
|
|
34
|
+
*/
|
|
35
|
+
class LocalSubscription {
|
|
36
|
+
#subject;
|
|
37
|
+
#queueGroup;
|
|
38
|
+
#max;
|
|
39
|
+
#count = 0;
|
|
40
|
+
#queue = [];
|
|
41
|
+
#waiters = [];
|
|
42
|
+
#closed = false;
|
|
43
|
+
#onClose;
|
|
44
|
+
|
|
45
|
+
constructor(subject, options, onClose) {
|
|
46
|
+
this.#subject = subject;
|
|
47
|
+
this.#queueGroup = options?.queue;
|
|
48
|
+
this.#max = options?.max;
|
|
49
|
+
this.#onClose = onClose;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
get subject() { return this.#subject; }
|
|
53
|
+
get queue() { return this.#queueGroup; }
|
|
54
|
+
get isClosed() { return this.#closed; }
|
|
55
|
+
|
|
56
|
+
deliver(event) {
|
|
57
|
+
if (this.#closed) { return; }
|
|
58
|
+
this.#count++;
|
|
59
|
+
if (this.#waiters.length > 0) {
|
|
60
|
+
this.#waiters.shift().resolve({ value: event, done: false });
|
|
61
|
+
} else {
|
|
62
|
+
this.#queue.push(event);
|
|
63
|
+
}
|
|
64
|
+
if (this.#max != null && this.#count >= this.#max) {
|
|
65
|
+
this.#close();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
#close() {
|
|
70
|
+
if (this.#closed) { return; }
|
|
71
|
+
this.#closed = true;
|
|
72
|
+
this.#onClose?.(this);
|
|
73
|
+
for (const w of this.#waiters) {
|
|
74
|
+
w.resolve({ value: undefined, done: true });
|
|
75
|
+
}
|
|
76
|
+
this.#waiters = [];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
unsubscribe() { this.#close(); }
|
|
80
|
+
drain() { this.#close(); return Promise.resolve(); }
|
|
81
|
+
|
|
82
|
+
[Symbol.asyncIterator]() {
|
|
83
|
+
return {
|
|
84
|
+
next: () => {
|
|
85
|
+
if (this.#queue.length > 0) {
|
|
86
|
+
return Promise.resolve({ value: this.#queue.shift(), done: false });
|
|
87
|
+
}
|
|
88
|
+
if (this.#closed) {
|
|
89
|
+
return Promise.resolve({ value: undefined, done: true });
|
|
90
|
+
}
|
|
91
|
+
const w = Promise.withResolvers();
|
|
92
|
+
this.#waiters.push(w);
|
|
93
|
+
return w.promise;
|
|
94
|
+
},
|
|
95
|
+
return: () => {
|
|
96
|
+
this.#close();
|
|
97
|
+
return Promise.resolve({ value: undefined, done: true });
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* In-memory NATS-shaped pub/sub bus. Implements the subset of `NatsConnection` that Geonix
|
|
105
|
+
* consumes: `publish`, `subscribe`, `drain`, `closed`, `status`, `info.max_payload`.
|
|
106
|
+
* Single-process only — no socket, no inter-process delivery.
|
|
107
|
+
*
|
|
108
|
+
* Semantics matched against real NATS:
|
|
109
|
+
* - Wildcards (`*`, `>`) per NATS subject spec.
|
|
110
|
+
* - Queue groups: one delivery per group per publish; round-robin within the group.
|
|
111
|
+
* - Plain (non-queue) subscriptions: each receives its own copy of every match.
|
|
112
|
+
* - `{ max: N }` auto-closes the subscription after N deliveries.
|
|
113
|
+
* - Within a subscription, FIFO.
|
|
114
|
+
* - Defensive copy on publish (publisher mutation after publish is safe).
|
|
115
|
+
*/
|
|
116
|
+
class LocalBus {
|
|
117
|
+
#subs = new Set();
|
|
118
|
+
#info = { max_payload: LOCAL_MAX_PAYLOAD };
|
|
119
|
+
#closed = false;
|
|
120
|
+
#closedDeferred = Promise.withResolvers();
|
|
121
|
+
#queueCounters = new Map();
|
|
122
|
+
|
|
123
|
+
get info() { return this.#info; }
|
|
124
|
+
|
|
125
|
+
isClosed() { return this.#closed; }
|
|
126
|
+
|
|
127
|
+
async publish(subject, data) {
|
|
128
|
+
if (this.#closed) { return; }
|
|
129
|
+
|
|
130
|
+
const buf = Buffer.from(data);
|
|
131
|
+
|
|
132
|
+
const byQueue = new Map();
|
|
133
|
+
const plain = [];
|
|
134
|
+
for (const sub of this.#subs) {
|
|
135
|
+
if (!subjectMatch(sub.subject, subject)) { continue; }
|
|
136
|
+
if (sub.queue) {
|
|
137
|
+
if (!byQueue.has(sub.queue)) { byQueue.set(sub.queue, []); }
|
|
138
|
+
byQueue.get(sub.queue).push(sub);
|
|
139
|
+
} else {
|
|
140
|
+
plain.push(sub);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
for (const sub of plain) {
|
|
145
|
+
sub.deliver({ data: buf, subject });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
for (const [group, members] of byQueue) {
|
|
149
|
+
const idx = (this.#queueCounters.get(group) ?? 0) % members.length;
|
|
150
|
+
this.#queueCounters.set(group, idx + 1);
|
|
151
|
+
members[idx].deliver({ data: buf, subject });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async subscribe(subject, options) {
|
|
156
|
+
if (this.#closed) {
|
|
157
|
+
throw new Error("LocalBus: subscribe on closed bus");
|
|
158
|
+
}
|
|
159
|
+
const sub = new LocalSubscription(subject, options, (s) => this.#subs.delete(s));
|
|
160
|
+
this.#subs.add(sub);
|
|
161
|
+
return sub;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async drain() {
|
|
165
|
+
if (this.#closed) { return; }
|
|
166
|
+
for (const sub of [...this.#subs]) {
|
|
167
|
+
sub.unsubscribe();
|
|
168
|
+
}
|
|
169
|
+
this.#closed = true;
|
|
170
|
+
this.#closedDeferred.resolve();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
closed() { return this.#closedDeferred.promise; }
|
|
174
|
+
|
|
175
|
+
status() {
|
|
176
|
+
// never-yielding async iterable — the bus has no transport to lose
|
|
177
|
+
return {
|
|
178
|
+
[Symbol.asyncIterator]() {
|
|
179
|
+
return {
|
|
180
|
+
next: () => new Promise(() => { /* never resolves */ }),
|
|
181
|
+
return: () => Promise.resolve({ value: undefined, done: true }),
|
|
182
|
+
};
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function createLocalBus() {
|
|
189
|
+
return new LocalBus();
|
|
190
|
+
}
|
package/src/Logger.js
CHANGED
|
@@ -5,12 +5,16 @@ const LEVEL = LEVELS[process.env.GX_LOG_LEVEL] ?? LEVELS.info;
|
|
|
5
5
|
const FORMAT = process.env.GX_LOG_FORMAT === "json" ? "json" : "text";
|
|
6
6
|
|
|
7
7
|
const defaultLoggerOptions = {
|
|
8
|
-
timestamp: true
|
|
8
|
+
timestamp: true,
|
|
9
9
|
};
|
|
10
10
|
|
|
11
11
|
function serialize(val) {
|
|
12
|
-
if (val instanceof Error) {
|
|
13
|
-
|
|
12
|
+
if (val instanceof Error) {
|
|
13
|
+
return val.stack || val.message;
|
|
14
|
+
}
|
|
15
|
+
if (typeof val === "object" && val !== null) {
|
|
16
|
+
return JSON.stringify(val);
|
|
17
|
+
}
|
|
14
18
|
return String(val);
|
|
15
19
|
}
|
|
16
20
|
|
|
@@ -22,7 +26,6 @@ function serialize(val) {
|
|
|
22
26
|
* The output format is controlled by `GX_LOG_FORMAT` (`text` | `json`, default `text`).
|
|
23
27
|
*/
|
|
24
28
|
export class Logger {
|
|
25
|
-
|
|
26
29
|
#options = defaultLoggerOptions;
|
|
27
30
|
#level = LEVEL;
|
|
28
31
|
#format = FORMAT;
|
|
@@ -46,10 +49,17 @@ export class Logger {
|
|
|
46
49
|
#log(level, ...args) {
|
|
47
50
|
const stream = level === "error" ? process.stderr : process.stdout;
|
|
48
51
|
if (this.#format === "json") {
|
|
49
|
-
stream.write(
|
|
52
|
+
stream.write(
|
|
53
|
+
JSON.stringify({ time: new Date().toISOString(), level, msg: args.map(serialize).join(" ") }) + "\n",
|
|
54
|
+
);
|
|
50
55
|
} else {
|
|
51
56
|
const ts = this.#options.timestamp ? new Date().toISOString() : undefined;
|
|
52
|
-
stream.write(
|
|
57
|
+
stream.write(
|
|
58
|
+
[ts, TAGS[level], ...args]
|
|
59
|
+
.filter(($) => $ !== undefined)
|
|
60
|
+
.map(serialize)
|
|
61
|
+
.join(" ") + "\n",
|
|
62
|
+
);
|
|
53
63
|
}
|
|
54
64
|
}
|
|
55
65
|
|
|
@@ -104,7 +114,6 @@ export class Logger {
|
|
|
104
114
|
setFormat(format) {
|
|
105
115
|
this.#format = format === "json" ? "json" : "text";
|
|
106
116
|
}
|
|
107
|
-
|
|
108
117
|
}
|
|
109
118
|
|
|
110
119
|
/**
|
package/src/Registry.js
CHANGED
|
@@ -17,7 +17,6 @@ const GARBAGE_COLLECTOR_INTERVAL = 500;
|
|
|
17
17
|
* @extends EventEmitter
|
|
18
18
|
*/
|
|
19
19
|
class Registry extends EventEmitter {
|
|
20
|
-
|
|
21
20
|
#isActive = false;
|
|
22
21
|
#registry = {};
|
|
23
22
|
#byIdentifier = new Map();
|
|
@@ -25,15 +24,15 @@ class Registry extends EventEmitter {
|
|
|
25
24
|
constructor() {
|
|
26
25
|
super();
|
|
27
26
|
|
|
28
|
-
this.#start().catch(e => logger.error("registry.start:", e));
|
|
27
|
+
this.#start().catch((e) => logger.error("registry.start:", e));
|
|
29
28
|
}
|
|
30
29
|
|
|
31
30
|
async #start() {
|
|
32
31
|
this.#isActive = true;
|
|
33
32
|
await connection.waitUntilReady();
|
|
34
33
|
|
|
35
|
-
this.#beaconListener().catch(e => logger.error("registry.beaconListener:", e));
|
|
36
|
-
this.#garbageCollector().catch(e => logger.error("registry.garbageCollector:", e));
|
|
34
|
+
this.#beaconListener().catch((e) => logger.error("registry.beaconListener:", e));
|
|
35
|
+
this.#garbageCollector().catch((e) => logger.error("registry.garbageCollector:", e));
|
|
37
36
|
}
|
|
38
37
|
|
|
39
38
|
/**
|
|
@@ -69,14 +68,17 @@ class Registry extends EventEmitter {
|
|
|
69
68
|
let firstFound = false;
|
|
70
69
|
const onFirstHealthy = () => {
|
|
71
70
|
if (!firstFound) {
|
|
72
|
-
firstFound = true;
|
|
71
|
+
firstFound = true;
|
|
72
|
+
resolveFirst();
|
|
73
73
|
}
|
|
74
74
|
};
|
|
75
75
|
|
|
76
76
|
// all checks run in parallel; background ones push into data.a,
|
|
77
77
|
// which is the same array reference spread into the registry entry below
|
|
78
78
|
// resolve promiseFirst when all checks are done so we don't wait on the timeout
|
|
79
|
-
Promise.all(allAddresses.map(a => this.#checkHealth(a, data.a, onFirstHealthy))).then(() =>
|
|
79
|
+
Promise.all(allAddresses.map((a) => this.#checkHealth(a, data.a, onFirstHealthy))).then(() =>
|
|
80
|
+
resolveFirst(),
|
|
81
|
+
);
|
|
80
82
|
|
|
81
83
|
try {
|
|
82
84
|
await withTimeout(promiseFirst, 5000);
|
|
@@ -87,7 +89,7 @@ class Registry extends EventEmitter {
|
|
|
87
89
|
|
|
88
90
|
this.#registry[data.i] = {
|
|
89
91
|
...data,
|
|
90
|
-
timeout: Date.now() + REGISTRY_ENTRY_TIMEOUT
|
|
92
|
+
timeout: Date.now() + REGISTRY_ENTRY_TIMEOUT,
|
|
91
93
|
};
|
|
92
94
|
|
|
93
95
|
const nameVersion = `${data.n}@${data.v}`;
|
|
@@ -124,7 +126,7 @@ class Registry extends EventEmitter {
|
|
|
124
126
|
}
|
|
125
127
|
|
|
126
128
|
// new service — dispatch registration without awaiting so the beacon loop stays unblocked
|
|
127
|
-
this.#registerService(data).catch(e => logger.error("registry.registerService:", e));
|
|
129
|
+
this.#registerService(data).catch((e) => logger.error("registry.registerService:", e));
|
|
128
130
|
}
|
|
129
131
|
}
|
|
130
132
|
|
|
@@ -144,7 +146,9 @@ class Registry extends EventEmitter {
|
|
|
144
146
|
const set = this.#byIdentifier.get(nameVersion);
|
|
145
147
|
if (set) {
|
|
146
148
|
set.delete(entry.i);
|
|
147
|
-
if (set.size === 0) {
|
|
149
|
+
if (set.size === 0) {
|
|
150
|
+
this.#byIdentifier.delete(nameVersion);
|
|
151
|
+
}
|
|
148
152
|
}
|
|
149
153
|
this.#byIdentifier.delete(entry.i);
|
|
150
154
|
|
|
@@ -174,8 +178,11 @@ class Registry extends EventEmitter {
|
|
|
174
178
|
*/
|
|
175
179
|
getEntriesForIdentifier(identifier) {
|
|
176
180
|
const ids = this.#byIdentifier.get(identifier);
|
|
177
|
-
if (!ids) {
|
|
178
|
-
|
|
181
|
+
if (!ids) {
|
|
182
|
+
return [];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return [...ids].map((i) => this.#registry[i]).filter(Boolean);
|
|
179
186
|
}
|
|
180
187
|
|
|
181
188
|
/**
|
|
@@ -215,7 +222,6 @@ class Registry extends EventEmitter {
|
|
|
215
222
|
return `${matches[0].n}@${matches[0].v}`;
|
|
216
223
|
}
|
|
217
224
|
}
|
|
218
|
-
|
|
219
225
|
}
|
|
220
226
|
|
|
221
227
|
/**
|
|
@@ -223,4 +229,4 @@ class Registry extends EventEmitter {
|
|
|
223
229
|
*
|
|
224
230
|
* @type {Registry}
|
|
225
231
|
*/
|
|
226
|
-
export const registry = new Registry();
|
|
232
|
+
export const registry = new Registry();
|
package/src/Remote.js
CHANGED
|
@@ -10,12 +10,16 @@ import { Request } from "./Request.js";
|
|
|
10
10
|
* @param {...any} context - Optional context values forwarded to the remote method.
|
|
11
11
|
* @returns {Proxy} A proxy whose properties are async functions that call the remote service.
|
|
12
12
|
*/
|
|
13
|
-
export const Remote = (service, ...context) =>
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
export const Remote = (service, ...context) =>
|
|
14
|
+
new Proxy(
|
|
15
|
+
{},
|
|
16
|
+
{
|
|
17
|
+
get: (_target, method) => {
|
|
18
|
+
if (typeof method !== "string" || method === "then") {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
18
21
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
22
|
+
return async (...args) => Request(service, method, args, context);
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
);
|
package/src/Request.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { connection } from "./Connection.js";
|
|
2
2
|
import { registry } from "./Registry.js";
|
|
3
|
-
import { fetchWithTimeout, hash } from "./Util.js";
|
|
3
|
+
import { fetchWithTimeout, hash, isLoopbackAddress } from "./Util.js";
|
|
4
4
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
5
5
|
import { _requestOptionsSymbol } from "./RequestOptions.js";
|
|
6
6
|
import { isStream, streamToString } from "./Stream.js";
|
|
@@ -23,7 +23,7 @@ function waitForIdentifier(name, version, id, timeout) {
|
|
|
23
23
|
return Promise.resolve(found);
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
return new Promise(resolve => {
|
|
26
|
+
return new Promise((resolve) => {
|
|
27
27
|
const timer = setTimeout(() => {
|
|
28
28
|
registry.removeListener("added", onAdded);
|
|
29
29
|
resolve(null);
|
|
@@ -98,7 +98,10 @@ export async function directRequest(identifier, method, args, context, options,
|
|
|
98
98
|
if (entries.length === 1) {
|
|
99
99
|
const addresses = entries[0].a || [];
|
|
100
100
|
if (addresses.length > 0) {
|
|
101
|
-
const
|
|
101
|
+
const loopback = addresses.filter(isLoopbackAddress);
|
|
102
|
+
const remote = addresses.filter((a) => !isLoopbackAddress(a));
|
|
103
|
+
const pool = loopback.length > 0 ? loopback : remote;
|
|
104
|
+
const address = pool[_httpRpcRoundRobin++ % pool.length];
|
|
102
105
|
|
|
103
106
|
const url = `http://${address}/!!_gx/rpc/${hash(entries[0].i)}`;
|
|
104
107
|
const rpcPayload = { m: method, a: args, c: context, o: originator };
|
|
@@ -109,11 +112,15 @@ export async function directRequest(identifier, method, args, context, options,
|
|
|
109
112
|
|
|
110
113
|
let httpResponse;
|
|
111
114
|
try {
|
|
112
|
-
const res = await fetchWithTimeout(
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
115
|
+
const res = await fetchWithTimeout(
|
|
116
|
+
url,
|
|
117
|
+
{
|
|
118
|
+
method: "POST",
|
|
119
|
+
headers: { "content-type": contentType },
|
|
120
|
+
body: fetchBody,
|
|
121
|
+
},
|
|
122
|
+
options?.httpTimeout ?? 5000,
|
|
123
|
+
);
|
|
117
124
|
if (res.ok) {
|
|
118
125
|
httpResponse = _payloadKey
|
|
119
126
|
? JSON.parse(decryptPayload(Buffer.from(await res.arrayBuffer())))
|
|
@@ -124,7 +131,9 @@ export async function directRequest(identifier, method, args, context, options,
|
|
|
124
131
|
}
|
|
125
132
|
|
|
126
133
|
if (httpResponse) {
|
|
127
|
-
if (httpResponse.e) {
|
|
134
|
+
if (httpResponse.e) {
|
|
135
|
+
throw Error(`Request: remote error: ${httpResponse.e}`);
|
|
136
|
+
}
|
|
128
137
|
if (isStream(httpResponse.r)) {
|
|
129
138
|
return JSON.parse(await streamToString(httpResponse.r));
|
|
130
139
|
}
|
|
@@ -142,19 +151,29 @@ export async function directRequest(identifier, method, args, context, options,
|
|
|
142
151
|
m: method,
|
|
143
152
|
a: args,
|
|
144
153
|
c: context,
|
|
145
|
-
o: originator
|
|
154
|
+
o: originator,
|
|
146
155
|
},
|
|
147
|
-
options
|
|
156
|
+
options,
|
|
157
|
+
);
|
|
148
158
|
|
|
149
159
|
// automatically process streamed response
|
|
150
160
|
if (isStream(response)) {
|
|
151
161
|
response = JSON.parse(await streamToString(response));
|
|
152
162
|
}
|
|
153
163
|
} catch (e) {
|
|
154
|
-
logger.debug(
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
164
|
+
logger.debug(
|
|
165
|
+
"GxError: directRequest",
|
|
166
|
+
inspect({
|
|
167
|
+
originator,
|
|
168
|
+
service: service ?? identifier,
|
|
169
|
+
method,
|
|
170
|
+
args,
|
|
171
|
+
context,
|
|
172
|
+
options,
|
|
173
|
+
error: e,
|
|
174
|
+
duration: Date.now() - requestBegin,
|
|
175
|
+
}),
|
|
176
|
+
);
|
|
158
177
|
|
|
159
178
|
throw e;
|
|
160
179
|
}
|
|
@@ -199,4 +218,4 @@ export async function Subscribe(subject, callback) {
|
|
|
199
218
|
for await (const event of subscription) {
|
|
200
219
|
callback(event.data);
|
|
201
220
|
}
|
|
202
|
-
}
|
|
221
|
+
}
|