odelic 0.0.0-stage → 0.2.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/src/server.js ADDED
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Tiny dependency-free HTTP/JSON API on top of Controller.
3
+ *
4
+ * GET /health
5
+ * GET /lights list lights (ping)
6
+ * GET /lights/<target> state ("all" = every light)
7
+ * POST /lights/<target>/on | off
8
+ * POST /lights/<target>/light {"color": 0..100, "brightness": 1..100}
9
+ * POST /lights/<target>/night {"level": 0..2}
10
+ * POST /groups/<id>/on | off | light
11
+ * POST /scenes/<index>/play | save
12
+ *
13
+ * Parameters may be sent as a JSON body or as query parameters. <target> is a light name, "all" or a vaddr.
14
+ * When a token is configured every request needs `Authorization: Bearer <token>` (or ?token=...).
15
+ */
16
+ import { createHash, timingSafeEqual } from 'node:crypto';
17
+ import http from 'node:http';
18
+
19
+ import { InputError } from './errors.js';
20
+ import { log } from './log.js';
21
+ import { toInt } from './controller.js';
22
+
23
+ export const LOOPBACK = ['127.0.0.1', 'localhost', '::1'];
24
+ const MAX_BODY = 65536;
25
+
26
+ export class HttpError extends Error {
27
+ constructor(status, message) {
28
+ super(message);
29
+ this.name = 'HttpError';
30
+ this.status = status;
31
+ }
32
+ }
33
+
34
+ function need(method, expected) {
35
+ if (method !== expected) throw new HttpError(405, `use ${expected}`);
36
+ }
37
+
38
+ /** Route one request to the controller. Returns the JSON-able result. */
39
+ export async function dispatch(ctl, method, path, params) {
40
+ let parts;
41
+ try {
42
+ parts = path.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean).map(decodeURIComponent);
43
+ } catch {
44
+ throw new HttpError(400, 'bad URL encoding');
45
+ }
46
+ if (parts.length === 1 && parts[0] === 'health') return { ok: true, ...ctl.info() };
47
+ if (!parts.length) throw new HttpError(404, 'not found');
48
+ const [head] = parts;
49
+ if (head === 'lights') {
50
+ if (parts.length === 1) {
51
+ need(method, 'GET');
52
+ return ctl.lights();
53
+ }
54
+ const target = parts[1];
55
+ if (parts.length === 2) {
56
+ need(method, 'GET');
57
+ return ctl.status(target);
58
+ }
59
+ need(method, 'POST');
60
+ const act = parts[2];
61
+ if (act === 'on') return ctl.on(target);
62
+ if (act === 'off') return ctl.off(target);
63
+ if (act === 'light') return ctl.setLight(target, params.color ?? 50, params.brightness ?? 100);
64
+ if (act === 'night') return ctl.nightLight(target, params.level ?? 0);
65
+ } else if (head === 'groups' && parts.length === 3) {
66
+ need(method, 'POST');
67
+ return ctl.group(toInt(parts[1], 'group id'), parts[2], params.color ?? 50, params.brightness ?? 100);
68
+ } else if (head === 'scenes' && parts.length === 3 && (parts[2] === 'play' || parts[2] === 'save')) {
69
+ need(method, 'POST');
70
+ return ctl.scene(toInt(parts[1], 'scene index'), parts[2] === 'save');
71
+ }
72
+ throw new HttpError(404, 'not found');
73
+ }
74
+
75
+ function readBody(req) {
76
+ return new Promise((resolve, reject) => {
77
+ const declared = Number(req.headers['content-length'] || 0);
78
+ if (declared > MAX_BODY) {
79
+ reject(new HttpError(413, 'body too large'));
80
+ return;
81
+ }
82
+ const chunks = [];
83
+ let size = 0;
84
+ req.on('data', (c) => {
85
+ size += c.length;
86
+ if (size > MAX_BODY) {
87
+ reject(new HttpError(413, 'body too large'));
88
+ req.destroy();
89
+ } else {
90
+ chunks.push(c);
91
+ }
92
+ });
93
+ req.on('end', () => resolve(Buffer.concat(chunks)));
94
+ req.on('error', reject);
95
+ });
96
+ }
97
+
98
+ const digest = (s) => createHash('sha256').update(String(s)).digest();
99
+
100
+ /** A `(req, res)` handler for node:http. */
101
+ export function makeHandler(ctl, token = null) {
102
+ return async (req, res) => {
103
+ let status = 200;
104
+ let payload;
105
+ try {
106
+ const url = new URL(req.url, 'http://localhost');
107
+ const params = Object.fromEntries(url.searchParams);
108
+ const body = await readBody(req);
109
+ if (body.length) {
110
+ let data;
111
+ try {
112
+ data = JSON.parse(body.toString('utf8'));
113
+ } catch {
114
+ throw new HttpError(400, 'body must be JSON');
115
+ }
116
+ if (data && typeof data === 'object' && !Array.isArray(data)) Object.assign(params, data);
117
+ }
118
+ if (token) {
119
+ const auth = req.headers.authorization || '';
120
+ const given = auth.toLowerCase().startsWith('bearer ') ? auth.slice(7) : (params.token ?? '');
121
+ if (!timingSafeEqual(digest(given), digest(token))) throw new HttpError(401, 'bad or missing token');
122
+ }
123
+ delete params.token;
124
+ payload = await dispatch(ctl, req.method.toUpperCase(), url.pathname, params);
125
+ } catch (e) {
126
+ if (e instanceof HttpError) {
127
+ status = e.status;
128
+ } else if (e instanceof InputError) {
129
+ status = 400;
130
+ } else { // BLE / login failures
131
+ status = 502;
132
+ log.error('request failed:', e);
133
+ }
134
+ payload = { ok: false, error: e instanceof HttpError || e instanceof InputError ? e.message : `${e.name}: ${e.message}` };
135
+ }
136
+ const data = Buffer.from(JSON.stringify(payload ?? null), 'utf8');
137
+ if (res.headersSent || res.destroyed) return;
138
+ res.writeHead(status, {
139
+ 'Content-Type': 'application/json; charset=utf-8',
140
+ 'Content-Length': data.length,
141
+ Connection: 'close',
142
+ });
143
+ res.end(data);
144
+ };
145
+ }
146
+
147
+ /**
148
+ * Start the HTTP API. Resolves to the listening http.Server; closing the server also closes the controller.
149
+ * @param {import('./controller.js').Controller} ctl
150
+ * @param {{host?: string, port?: number, token?: string|null}} [opts]
151
+ */
152
+ export async function serve(ctl, { host = '127.0.0.1', port = 8765, token = null } = {}) {
153
+ if (!LOOPBACK.includes(host) && !token) {
154
+ throw new InputError(`refusing to listen on ${host} without a token (use --token or ODELIC_HTTP_TOKEN)`);
155
+ }
156
+ const server = http.createServer(makeHandler(ctl, token));
157
+ server.headersTimeout = 10_000;
158
+ server.requestTimeout = 10_000 + 60_000; // BLE operations (scan + login) can take a while
159
+ await new Promise((resolve, reject) => {
160
+ server.once('error', reject);
161
+ server.listen(port, host, () => {
162
+ server.removeListener('error', reject);
163
+ resolve();
164
+ });
165
+ });
166
+ server.on('close', () => ctl.close().catch(() => {}));
167
+ const addr = server.address();
168
+ log.warning(`HTTP API on http://${host}:${addr.port} (${ctl.keepConnected
169
+ ? 'keeping the BLE link open' : `idle disconnect after ${ctl.idleTimeout}s`})`);
170
+ if (ctl.keepConnected) {
171
+ ctl.connect()
172
+ .then(() => log.warning(`connected via ${ctl.mesh.nodeName}`))
173
+ .catch((e) => log.warning(`initial connect failed: ${e.message}`)); // the next request retries
174
+ }
175
+ return server;
176
+ }
package/src/version.js ADDED
@@ -0,0 +1 @@
1
+ export const VERSION = '0.2.0';