doover-js 0.9.1 → 0.10.1

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.
@@ -5,6 +5,40 @@ const errors_1 = require("../http/errors");
5
5
  const snowflake_1 = require("../utils/snowflake");
6
6
  const RECONNECT_BASE_MS = 1000;
7
7
  const RECONNECT_CAP_MS = 30000;
8
+ /** Schemes a WebSocket can be opened on — it maps http(s) to ws(s) itself. */
9
+ const ALLOWED_WSS_PROTOCOLS = new Set(["ws:", "wss:", "http:", "https:"]);
10
+ /**
11
+ * Reject a `dataWssUrl` that cannot address a gateway.
12
+ *
13
+ * An empty or relative value is the dangerous case, because nothing rejects it
14
+ * on its own: the WebSocket constructor resolves a relative URL against the
15
+ * document and maps https to wss, so in a browser `new WebSocket("")` silently
16
+ * points the gateway at whatever page is open. That page answers the upgrade
17
+ * with its own HTML and a 200, the browser reports "the server did not accept
18
+ * the WebSocket handshake", and the client retries against it forever — a loop
19
+ * that looks like a server problem and never names the real cause. It reached
20
+ * a kiosk panel that way, from a client built with `dataWssUrl: ""` because the
21
+ * build it was bundled into defined no env for it.
22
+ *
23
+ * Absolute http(s) is allowed as well as ws(s), since the WebSocket
24
+ * constructor maps those itself and existing configs rely on it.
25
+ */
26
+ function assertAbsoluteWssUrl(url) {
27
+ if (typeof url !== "string" || url.trim() === "") {
28
+ throw new Error("dataWssUrl is empty, so a gateway connection would resolve against the " +
29
+ "current page rather than a gateway; set it to an absolute ws(s):// URL");
30
+ }
31
+ let parsed;
32
+ try {
33
+ parsed = new URL(url);
34
+ }
35
+ catch {
36
+ throw new Error(`dataWssUrl must be an absolute ws(s):// URL, not the relative value ${JSON.stringify(url)}`);
37
+ }
38
+ if (!ALLOWED_WSS_PROTOCOLS.has(parsed.protocol)) {
39
+ throw new Error(`dataWssUrl must be ws(s):// or http(s)://, not ${parsed.protocol}//`);
40
+ }
41
+ }
8
42
  class GatewayClient {
9
43
  constructor(config, auth) {
10
44
  this.config = config;
@@ -90,6 +124,10 @@ class GatewayClient {
90
124
  }
91
125
  }
92
126
  async openSocket() {
127
+ // Before auth, before any socket: a URL that cannot address a gateway is a
128
+ // configuration fault, and finding out now costs one error instead of an
129
+ // endless handshake failure that reads like a broken server.
130
+ assertAbsoluteWssUrl(this.config.dataWssUrl);
93
131
  if (this.auth) {
94
132
  await this.auth.ensureReady();
95
133
  }
@@ -32,6 +32,13 @@ export interface UseSendRpcOptions {
32
32
  * `["doover", "agent", agentId, "channel", channelName, "rpc", method]`.
33
33
  */
34
34
  mutationKey?: readonly unknown[];
35
+ /** How long to wait to hear from the device at all. See `SendRpcOptions`. */
36
+ timeoutMs?: number;
37
+ /**
38
+ * How long the command may then stay in flight once the device has answered,
39
+ * re-armed on every progress report. See `SendRpcOptions`.
40
+ */
41
+ pendingTimeoutMs?: number;
35
42
  }
36
43
  export interface UseSendRpcResult<TRequest, TResponse, TPending> extends Omit<UseMutationResult<TResponse, unknown, SendRpcVariables<TRequest>, void>, "isPending" | "mutate" | "mutateAsync"> {
37
44
  /** Most recent status across all commands (by `submittedAt`). */
@@ -120,6 +120,12 @@ function useSendRpc(identifier, options) {
120
120
  }
121
121
  return client.rpc.send({ agentId: identifier.agentId, channelName: identifier.channelName }, rpcRequest, {
122
122
  onStatus: (status) => appendStatus(commandId, status),
123
+ ...(options.timeoutMs !== undefined
124
+ ? { timeoutMs: options.timeoutMs }
125
+ : {}),
126
+ ...(options.pendingTimeoutMs !== undefined
127
+ ? { pendingTimeoutMs: options.pendingTimeoutMs }
128
+ : {}),
123
129
  });
124
130
  },
125
131
  onSuccess: (data, variables) => settleCommand(variables.commandId, {
@@ -5,7 +5,20 @@ import type { RpcRequest, RpcStatus } from "../types/common";
5
5
  export interface SendRpcOptions<TPending = undefined> {
6
6
  onStatus?: (status: RpcStatus<TPending>) => void;
7
7
  signal?: AbortSignal;
8
+ /**
9
+ * How long to wait to hear from the device at all. A healthy device answers
10
+ * within seconds however long the work then takes, so keep this short and
11
+ * use `pendingTimeoutMs` for the work itself.
12
+ */
8
13
  timeoutMs?: number;
14
+ /**
15
+ * How long the command may then stay in flight once the device has answered
16
+ * it — acknowledged it, or reported progress. Re-armed on every subsequent
17
+ * status update, so a device that keeps reporting keeps its command alive,
18
+ * while one that answers and then dies still fails. Without this, a handler
19
+ * that legitimately runs longer than `timeoutMs` is killed mid-flight.
20
+ */
21
+ pendingTimeoutMs?: number;
9
22
  }
10
23
  interface ChannelIdentifierLike {
11
24
  agentId: string;
@@ -2,6 +2,10 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.RpcDispatcher = void 0;
4
4
  const errors_1 = require("./errors");
5
+ /** Whether the device has answered this command, rather than only received it. */
6
+ function hasDeviceAnswered(status) {
7
+ return status.code === "acknowledged" || status.code === "pending";
8
+ }
5
9
  function isRpcMessageData(data) {
6
10
  return (typeof data === "object" &&
7
11
  data !== null &&
@@ -45,6 +49,7 @@ class RpcDispatcher {
45
49
  channelKey,
46
50
  channel: channelRef,
47
51
  startedAt,
52
+ pendingTimeoutMs: options?.pendingTimeoutMs,
48
53
  };
49
54
  if (options?.signal) {
50
55
  const onAbort = () => this.settle(message.id, "abort", undefined, options.signal?.reason);
@@ -109,6 +114,15 @@ class RpcDispatcher {
109
114
  else if (status.code === "error") {
110
115
  this.settle(msg.id, "error", undefined, new errors_1.DooverRpcError(status, pending.request));
111
116
  }
117
+ else if (hasDeviceAnswered(status) && pending.pendingTimeoutMs !== undefined) {
118
+ // The device is alive and working: swap the "no response" deadline for
119
+ // the pending budget, and re-arm it on every report that follows.
120
+ if (pending.timer)
121
+ clearTimeout(pending.timer);
122
+ pending.timer = setTimeout(() => {
123
+ this.settle(msg.id, "timeout", undefined, new Error("Device stopped reporting progress"));
124
+ }, pending.pendingTimeoutMs);
125
+ }
112
126
  }
113
127
  settle(messageId, outcome, response, rejection) {
114
128
  const pending = this.pending.get(messageId);
@@ -393,4 +393,82 @@ class RejectingAuth extends doover_auth_1.DooverAuth {
393
393
  (0, chai_1.expect)(helpers_1.MockWebSocket.instances.length).to.equal(11);
394
394
  randomStub.restore();
395
395
  });
396
+ (0, mocha_1.describe)("an unusable dataWssUrl", () => {
397
+ // A relative or empty value is resolved against the document by the
398
+ // WebSocket constructor, so the gateway silently points at whatever page
399
+ // is open, gets that page's HTML and a 200 back, and retries against it
400
+ // forever. Refusing it names the cause once instead.
401
+ (0, mocha_1.it)("refuses an empty one rather than resolving against the page", async () => {
402
+ const client = new gateway_client_1.GatewayClient({
403
+ dataRestUrl: "https://api.example.com",
404
+ controlApiUrl: "https://control.example.com",
405
+ dataWssUrl: "",
406
+ organisationId: null,
407
+ sharing: "none",
408
+ impersonateUserStorageKey: "doover:test",
409
+ });
410
+ await (0, chai_1.expect)(client.connect()).to.be.rejectedWith(/dataWssUrl is empty/);
411
+ (0, chai_1.expect)(helpers_1.MockWebSocket.instances).to.have.length(0);
412
+ });
413
+ (0, mocha_1.it)("refuses a relative one", async () => {
414
+ const client = new gateway_client_1.GatewayClient({
415
+ dataRestUrl: "https://api.example.com",
416
+ controlApiUrl: "https://control.example.com",
417
+ dataWssUrl: "/widget/some_widget?app_key=k",
418
+ organisationId: null,
419
+ sharing: "none",
420
+ impersonateUserStorageKey: "doover:test",
421
+ });
422
+ await (0, chai_1.expect)(client.connect()).to.be.rejectedWith(/not the relative value/);
423
+ (0, chai_1.expect)(helpers_1.MockWebSocket.instances).to.have.length(0);
424
+ });
425
+ (0, mocha_1.it)("refuses a scheme a WebSocket cannot be opened on", async () => {
426
+ const client = new gateway_client_1.GatewayClient({
427
+ dataRestUrl: "https://api.example.com",
428
+ controlApiUrl: "https://control.example.com",
429
+ dataWssUrl: "file:///etc/hosts",
430
+ organisationId: null,
431
+ sharing: "none",
432
+ impersonateUserStorageKey: "doover:test",
433
+ });
434
+ await (0, chai_1.expect)(client.connect()).to.be.rejectedWith(/must be ws\(s\):\/\/ or http\(s\)/);
435
+ (0, chai_1.expect)(helpers_1.MockWebSocket.instances).to.have.length(0);
436
+ });
437
+ (0, mocha_1.it)("reports it through wssError once when a subscribe connects in the background", async () => {
438
+ // `subscribe` is fire-and-forget, so the fault has to surface as an event
439
+ // rather than a rejected promise nobody holds — and only once, because
440
+ // reconnects are scheduled on socket close and no socket was ever opened.
441
+ const client = new gateway_client_1.GatewayClient({
442
+ dataRestUrl: "https://api.example.com",
443
+ controlApiUrl: "https://control.example.com",
444
+ dataWssUrl: "",
445
+ organisationId: null,
446
+ sharing: "none",
447
+ impersonateUserStorageKey: "doover:test",
448
+ });
449
+ const errors = [];
450
+ client.on("wssError", (event) => errors.push(String(event.message)));
451
+ client.subscribe({ agent_id: "111", name: "tag_values" });
452
+ // The rejection travels connect -> openSocket -> catch, which is several
453
+ // microtasks; draining them is not the same as advancing fake timers.
454
+ for (let tick = 0; tick < 5; tick += 1)
455
+ await Promise.resolve();
456
+ (0, chai_1.expect)(errors).to.have.length(1);
457
+ (0, chai_1.expect)(errors[0]).to.match(/dataWssUrl is empty/);
458
+ (0, chai_1.expect)(helpers_1.MockWebSocket.instances).to.have.length(0);
459
+ });
460
+ (0, mocha_1.it)("still accepts an absolute http(s) URL, which WebSocket maps itself", async () => {
461
+ const client = new gateway_client_1.GatewayClient({
462
+ dataRestUrl: "https://api.example.com",
463
+ controlApiUrl: "https://control.example.com",
464
+ dataWssUrl: "https://ws.example.com/gateway",
465
+ organisationId: null,
466
+ sharing: "none",
467
+ impersonateUserStorageKey: "doover:test",
468
+ });
469
+ await client.connect();
470
+ (0, chai_1.expect)(helpers_1.MockWebSocket.instances).to.have.length(1);
471
+ (0, chai_1.expect)(helpers_1.MockWebSocket.instances[0].url).to.equal("https://ws.example.com/gateway");
472
+ });
473
+ });
396
474
  });
@@ -188,3 +188,64 @@ describe("RpcDispatcher stats integration", () => {
188
188
  (0, chai_1.expect)(snap.rpc.peakPendingRpcs).to.equal(1);
189
189
  });
190
190
  });
191
+ describe("RpcDispatcher pending timeout", () => {
192
+ const setup = (id) => {
193
+ const gw = makeFakeGateway();
194
+ const messages = makeFakeMessagesApi();
195
+ messages.setNextId(id);
196
+ const dispatcher = new rpc_dispatcher_1.RpcDispatcher(gw, messages);
197
+ return { gw, dispatcher, channel: { agent_id: "a1", name: "c1" } };
198
+ };
199
+ it("replaces the initial timeout once the device answers", async () => {
200
+ const { gw, dispatcher, channel } = setup("rpc-p1");
201
+ const promise = dispatcher.send({ agentId: "a1", channelName: "c1" }, { method: "do", request: {} }, { timeoutMs: 10, pendingTimeoutMs: 200 });
202
+ await new Promise((r) => setImmediate(r));
203
+ // Acknowledged before the 10ms deadline: the command must survive well past
204
+ // it, because the device has proven it is working.
205
+ gw.emitMessageUpdate(rpcMessage("rpc-p1", channel, { code: "acknowledged", message: { timestamp: 1 } }, {}));
206
+ await new Promise((r) => setTimeout(r, 60));
207
+ gw.emitMessageUpdate(rpcMessage("rpc-p1", channel, { code: "success" }, {}, { ok: true }));
208
+ (0, chai_1.expect)(await promise).to.deep.equal({ ok: true });
209
+ });
210
+ it("re-arms the pending budget on every progress report", async () => {
211
+ const { gw, dispatcher, channel } = setup("rpc-p2");
212
+ const promise = dispatcher.send({ agentId: "a1", channelName: "c1" }, { method: "do", request: {} }, { timeoutMs: 10, pendingTimeoutMs: 50 });
213
+ await new Promise((r) => setImmediate(r));
214
+ // Four reports, each well inside the 50ms budget but cumulatively past it.
215
+ for (let i = 0; i < 4; i += 1) {
216
+ gw.emitMessageUpdate(rpcMessage("rpc-p2", channel, { code: "pending", message: { text: `step ${i}` } }, {}));
217
+ await new Promise((r) => setTimeout(r, 30));
218
+ }
219
+ gw.emitMessageUpdate(rpcMessage("rpc-p2", channel, { code: "success" }, {}, { ok: 1 }));
220
+ (0, chai_1.expect)(await promise).to.deep.equal({ ok: 1 });
221
+ });
222
+ it("fails a device that answers and then goes quiet", async () => {
223
+ const { gw, dispatcher, channel } = setup("rpc-p3");
224
+ const promise = dispatcher.send({ agentId: "a1", channelName: "c1" }, { method: "do", request: {} }, { timeoutMs: 1000, pendingTimeoutMs: 20 });
225
+ await new Promise((r) => setImmediate(r));
226
+ gw.emitMessageUpdate(rpcMessage("rpc-p3", channel, { code: "acknowledged", message: { timestamp: 1 } }, {}));
227
+ let caught;
228
+ try {
229
+ await promise;
230
+ }
231
+ catch (e) {
232
+ caught = e;
233
+ }
234
+ // Distinct from "RPC timed out": it answered, then stopped.
235
+ (0, chai_1.expect)(caught?.message).to.equal("Device stopped reporting progress");
236
+ });
237
+ it("keeps the flat deadline when no pending budget is given", async () => {
238
+ const { gw, dispatcher, channel } = setup("rpc-p4");
239
+ const promise = dispatcher.send({ agentId: "a1", channelName: "c1" }, { method: "do", request: {} }, { timeoutMs: 20 });
240
+ await new Promise((r) => setImmediate(r));
241
+ gw.emitMessageUpdate(rpcMessage("rpc-p4", channel, { code: "acknowledged", message: { timestamp: 1 } }, {}));
242
+ let caught;
243
+ try {
244
+ await promise;
245
+ }
246
+ catch (e) {
247
+ caught = e;
248
+ }
249
+ (0, chai_1.expect)(caught?.message).to.equal("RPC timed out");
250
+ });
251
+ });
@@ -12,6 +12,16 @@ const path_parsing_1 = require("../viewer/path-parsing");
12
12
  (0, chai_1.expect)(extracted.timestamp).to.equal(date.getTime());
13
13
  (0, chai_1.expect)(id).to.match(/^\d+$/);
14
14
  });
15
+ (0, mocha_1.it)("floors a pre-epoch time at the oldest addressable id", () => {
16
+ // Ids are unsigned; the API rejects a negative one outright and fails the whole
17
+ // request, so a caller reaching further back than 2025-01-01 gets the epoch.
18
+ (0, chai_1.expect)((0, snowflake_1.generateSnowflakeIdAtTime)(new Date("2023-11-18T02:58:19.326Z"))).to.equal("0");
19
+ (0, chai_1.expect)((0, snowflake_1.generateSnowflakeIdAtTime)(new Date("2025-01-01T00:00:00.000Z"))).to.equal("0");
20
+ (0, chai_1.expect)((0, snowflake_1.generateSnowflakeIdAtTime)(new Date(0))).to.equal("0");
21
+ });
22
+ (0, mocha_1.it)("rejects an invalid time rather than emitting a garbage id", () => {
23
+ (0, chai_1.expect)(() => (0, snowflake_1.generateSnowflakeIdAtTime)(new Date("nonsense"))).to.throw(TypeError);
24
+ });
15
25
  (0, mocha_1.it)("adds an epoch-ms timestamp to messages", () => {
16
26
  const date = new Date("2026-01-02T03:04:05.000Z");
17
27
  const id = (0, snowflake_1.generateSnowflakeIdAtTime)(date);
@@ -146,9 +146,18 @@ export interface DataSeries {
146
146
  __source?: SourceProvenance;
147
147
  }
148
148
  /**
149
- * Lifecycle status for an RPC. `pending` carries arbitrary intermediate
150
- * payloads (progress updates) emitted by the server while the request is
151
- * in flight.
149
+ * Lifecycle status for an RPC.
150
+ *
151
+ * `sent` is written by the sender; the other non-terminal codes come from the
152
+ * device handling the request. `acknowledged` means it has picked the request
153
+ * up, and `pending` carries arbitrary intermediate payloads (progress updates)
154
+ * it emits while still working — by convention a `{ text }` summary, plus
155
+ * whatever structured fields the handler wants alongside it. Both prove the
156
+ * device is alive, so consumers should treat either as a reason to extend how
157
+ * long they are willing to wait (see `SendRpcOptions.pendingTimeoutMs`), never
158
+ * as a completion.
159
+ *
160
+ * `success` and `error` are the only terminal codes.
152
161
  */
153
162
  export type RpcStatus<TPending = undefined> = {
154
163
  code: "awaiting_confirmation";
@@ -1,3 +1,11 @@
1
+ /**
2
+ * Snowflake id for a point in time, floored at the epoch.
3
+ *
4
+ * Ids are unsigned, so a pre-epoch time has no id to generate. Returning the
5
+ * negative arithmetic result produced a value the API rejects outright
6
+ * ("expected a snowflake id value"), failing the whole request; `0` is the
7
+ * oldest addressable id, which is what a caller reaching further back wants.
8
+ */
1
9
  export declare function generateSnowflakeIdAtTime(time: {
2
10
  valueOf(): number;
3
11
  }): string;
@@ -3,10 +3,22 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.generateSnowflakeIdAtTime = generateSnowflakeIdAtTime;
4
4
  exports.extractSnowflakeId = extractSnowflakeId;
5
5
  exports.addTimestampToMessage = addTimestampToMessage;
6
+ /** Doover's snowflake epoch: 2025-01-01T00:00:00Z. Nothing predates it. */
7
+ const SNOWFLAKE_EPOCH_MS = 1735689600000;
8
+ /**
9
+ * Snowflake id for a point in time, floored at the epoch.
10
+ *
11
+ * Ids are unsigned, so a pre-epoch time has no id to generate. Returning the
12
+ * negative arithmetic result produced a value the API rejects outright
13
+ * ("expected a snowflake id value"), failing the whole request; `0` is the
14
+ * oldest addressable id, which is what a caller reaching further back wants.
15
+ */
6
16
  function generateSnowflakeIdAtTime(time) {
7
- const offset = 1735689600000;
8
- const bigTime = BigInt(time.valueOf() - offset);
9
- const bigId = bigTime << 22n;
17
+ const offsetMs = time.valueOf() - SNOWFLAKE_EPOCH_MS;
18
+ if (Number.isNaN(offsetMs)) {
19
+ throw new TypeError("generateSnowflakeIdAtTime received an invalid time");
20
+ }
21
+ const bigId = BigInt(Math.max(0, offsetMs)) << 22n;
10
22
  return bigId.toString();
11
23
  }
12
24
  function extractSnowflakeId(id) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doover-js",
3
- "version": "0.9.1",
3
+ "version": "0.10.1",
4
4
  "description": "TypeScript client for Doover.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",