whiskops-sdk 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.
Files changed (3) hide show
  1. package/README.md +34 -0
  2. package/package.json +17 -0
  3. package/src/index.js +125 -0
package/README.md ADDED
@@ -0,0 +1,34 @@
1
+ # @codemenschen/whiskops-sdk (Node.js)
2
+
3
+ Client SDK for the Whisk device External API. Wraps bearer-token auth,
4
+ idempotency-key generation for `turn`, retry with backoff on 429/5xx, and
5
+ client-side spacing between `turn` calls.
6
+
7
+ ## Usage
8
+
9
+ ```js
10
+ import { WhiskClient } from "./src/index.js";
11
+
12
+ const client = new WhiskClient({ apiKey: process.env.WHISK_API_KEY });
13
+
14
+ const status = await client.status();
15
+ if (status.online) {
16
+ await client.turn({ speed: 80 });
17
+ await client.stop();
18
+ }
19
+ ```
20
+
21
+ ## Notes
22
+
23
+ - `turn()` validates `speed` is an integer 1-500 before making a request.
24
+ - A 409 response (active Time/Turns session) is not retried — it is thrown
25
+ as a `WhiskApiError` since retrying would not resolve the conflict.
26
+ - `baseUrl` defaults to `https://myremotedevice.com/api/v1/external` per the
27
+ published API docs; override it via the constructor if the real production
28
+ host differs.
29
+
30
+ ## Tests
31
+
32
+ ```
33
+ npm test
34
+ ```
package/package.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "whiskops-sdk",
3
+ "version": "0.1.0",
4
+ "description": "Client SDK for the Whisk device External API (status, turn, stop) with built-in idempotency-key handling and retry/backoff.",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "files": ["src", "README.md"],
8
+ "engines": {
9
+ "node": ">=18"
10
+ },
11
+ "scripts": {
12
+ "test": "node --test"
13
+ },
14
+ "keywords": ["whisk", "iot", "device-control", "sdk"],
15
+ "author": "Codemenschen GmbH",
16
+ "license": "MIT"
17
+ }
package/src/index.js ADDED
@@ -0,0 +1,125 @@
1
+ const DEFAULT_BASE_URL = "https://myremotedevice.com/api/v1/external";
2
+ const MIN_SPEED = 1;
3
+ const MAX_SPEED = 500;
4
+ const MIN_TURN_INTERVAL_MS = 1000;
5
+
6
+ export class WhiskApiError extends Error {
7
+ constructor(message, { status, body } = {}) {
8
+ super(message);
9
+ this.name = "WhiskApiError";
10
+ this.status = status;
11
+ this.body = body;
12
+ }
13
+ }
14
+
15
+ function sleep(ms) {
16
+ return new Promise((resolve) => setTimeout(resolve, ms));
17
+ }
18
+
19
+ /**
20
+ * Client for the Whisk device External API.
21
+ *
22
+ * Wraps authentication, idempotency-key generation for `turn`, and retry with
23
+ * exponential backoff on transient failures (429 / 5xx). A 409 from `turn`
24
+ * (an active Time/Turns session) is not retried — it is returned to the caller
25
+ * as an error since retrying blindly would not help.
26
+ */
27
+ export class WhiskClient {
28
+ /**
29
+ * @param {object} opts
30
+ * @param {string} opts.apiKey - Bearer token (`twk_...`) for a single device.
31
+ * @param {string} [opts.baseUrl] - Override the External API base URL.
32
+ * @param {number} [opts.maxRetries] - Max retry attempts on 429/5xx (default 3).
33
+ * @param {number} [opts.minTurnIntervalMs] - Client-side spacing enforced between
34
+ * `turn` calls, mirroring the documented "at least 1 second apart" limit.
35
+ */
36
+ constructor({ apiKey, baseUrl = DEFAULT_BASE_URL, maxRetries = 3, minTurnIntervalMs = MIN_TURN_INTERVAL_MS } = {}) {
37
+ if (!apiKey) throw new Error("WhiskClient requires apiKey");
38
+ this.apiKey = apiKey;
39
+ this.baseUrl = baseUrl.replace(/\/$/, "");
40
+ this.maxRetries = maxRetries;
41
+ this.minTurnIntervalMs = minTurnIntervalMs;
42
+ this._lastTurnAt = 0;
43
+ }
44
+
45
+ async _request(path, { method = "GET", headers = {}, body } = {}) {
46
+ const url = `${this.baseUrl}${path}`;
47
+ const reqHeaders = {
48
+ Authorization: `Bearer ${this.apiKey}`,
49
+ ...headers,
50
+ };
51
+ if (body !== undefined) reqHeaders["Content-Type"] = "application/json";
52
+
53
+ let attempt = 0;
54
+ for (;;) {
55
+ const res = await fetch(url, {
56
+ method,
57
+ headers: reqHeaders,
58
+ body: body !== undefined ? JSON.stringify(body) : undefined,
59
+ });
60
+
61
+ if (res.status === 429 || res.status >= 500) {
62
+ if (attempt >= this.maxRetries) {
63
+ const text = await res.text().catch(() => "");
64
+ throw new WhiskApiError(`${method} ${path} failed after ${attempt + 1} attempts: ${res.status}`, {
65
+ status: res.status,
66
+ body: text,
67
+ });
68
+ }
69
+ const retryAfter = Number(res.headers.get("retry-after"));
70
+ const backoffMs = Number.isFinite(retryAfter) && retryAfter > 0
71
+ ? retryAfter * 1000
72
+ : 2 ** attempt * 500;
73
+ await sleep(backoffMs);
74
+ attempt += 1;
75
+ continue;
76
+ }
77
+
78
+ if (!res.ok) {
79
+ const text = await res.text().catch(() => "");
80
+ throw new WhiskApiError(`${method} ${path} failed: ${res.status}`, { status: res.status, body: text });
81
+ }
82
+
83
+ if (res.status === 204) return null;
84
+ return res.json().catch(() => null);
85
+ }
86
+ }
87
+
88
+ /** Read current device status. Offline devices return `{ online: false }` successfully. */
89
+ status() {
90
+ return this._request("/device/status");
91
+ }
92
+
93
+ /**
94
+ * Start a single turn.
95
+ * @param {object} opts
96
+ * @param {number} opts.speed - Integer 1-500.
97
+ * @param {string} [opts.idempotencyKey] - Supply your own to control retries/dedup
98
+ * explicitly; otherwise one is generated per call.
99
+ */
100
+ async turn({ speed, idempotencyKey } = {}) {
101
+ if (!Number.isInteger(speed) || speed < MIN_SPEED || speed > MAX_SPEED) {
102
+ throw new RangeError(`speed must be an integer between ${MIN_SPEED} and ${MAX_SPEED}`);
103
+ }
104
+
105
+ const sinceLast = Date.now() - this._lastTurnAt;
106
+ if (sinceLast < this.minTurnIntervalMs) {
107
+ await sleep(this.minTurnIntervalMs - sinceLast);
108
+ }
109
+ this._lastTurnAt = Date.now();
110
+
111
+ const key = idempotencyKey || crypto.randomUUID();
112
+ return this._request("/device/turn", {
113
+ method: "POST",
114
+ headers: { "Idempotency-Key": key },
115
+ body: { speed },
116
+ });
117
+ }
118
+
119
+ /** Stop the device. Also ends any active Time/Turns session. */
120
+ stop() {
121
+ return this._request("/device/stop", { method: "POST" });
122
+ }
123
+ }
124
+
125
+ export default WhiskClient;