smart-home-engine 0.0.1 → 0.11.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 +76 -0
- package/dist/web/assets/codicon-DCmgc-ay.ttf +0 -0
- package/dist/web/assets/index-BbwiXmS-.css +1 -0
- package/dist/web/assets/index-DD-XScWV.js +140 -0
- package/dist/web/assets/monaco-langs-DZ6hB11b.js +1423 -0
- package/dist/web/assets/monaco-langs-DyX1CsEw.css +1 -0
- package/dist/web/assets/tsMode-DxTbjAE2.js +16 -0
- package/dist/web/index.html +164 -0
- package/dist/web/monacoeditorwork/editor.worker.bundle.js +13519 -0
- package/dist/web/monacoeditorwork/ts.worker.bundle.js +256353 -0
- package/package.json +85 -10
- package/src/config.js +56 -0
- package/src/elastic.js +19 -0
- package/src/index.js +1192 -0
- package/src/influx.js +25 -0
- package/src/lib/mqtt-wildcards.js +34 -0
- package/src/lib/parse-payload.js +29 -0
- package/src/lib/redis.js +74 -0
- package/src/lib/shedb-core.js +447 -0
- package/src/lib/shedb-worker.js +126 -0
- package/src/lib/state-store.js +97 -0
- package/src/lib/storage.js +74 -0
- package/src/matter/controller.js +307 -0
- package/src/sandbox/api.js +57 -0
- package/src/sandbox/elastic-sandbox.js +88 -0
- package/src/sandbox/influx-sandbox.js +107 -0
- package/src/sandbox/matter-sandbox.js +92 -0
- package/src/sandbox/shedb-sandbox.js +89 -0
- package/src/sandbox/stdlib.js +132 -0
- package/src/scripts/hello.js +3 -0
- package/src/web/ai-api.js +443 -0
- package/src/web/auth.js +186 -0
- package/src/web/config-api.js +34 -0
- package/src/web/deps-api.js +138 -0
- package/src/web/git-api.js +188 -0
- package/src/web/log-ws.js +78 -0
- package/src/web/matter-api.js +102 -0
- package/src/web/mqtt-api.js +65 -0
- package/src/web/scripts-api.js +192 -0
- package/src/web/server.js +139 -0
- package/src/web/shedb-api.js +140 -0
- package/src/web/shedb.js +168 -0
- package/index.js +0 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const express = require('express');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
const router = express.Router();
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Resolve a safe absolute path within the script root.
|
|
11
|
+
* Returns null if the resolved path escapes the root (traversal attempt).
|
|
12
|
+
* @param {string} root - absolute script directory
|
|
13
|
+
* @param {string} relPath - client-supplied relative path
|
|
14
|
+
* @returns {string|null}
|
|
15
|
+
*/
|
|
16
|
+
function safePath(root, relPath) {
|
|
17
|
+
const abs = path.resolve(root, relPath.replace(/^\/+/, ''));
|
|
18
|
+
if (!abs.startsWith(root + path.sep) && abs !== root) return null;
|
|
19
|
+
return abs;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function getRoot(req) {
|
|
23
|
+
return req.app.locals.scriptDir || null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** True if the directory itself contains a .shelib marker. */
|
|
27
|
+
function hasShelibMarker(absDir) {
|
|
28
|
+
return fs.existsSync(path.join(absDir, '.shelib'));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Flat list of all .js files with metadata and lib flag. */
|
|
32
|
+
function walk(dir, base, parentIsLib) {
|
|
33
|
+
let entries;
|
|
34
|
+
try {
|
|
35
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
36
|
+
} catch {
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
const lib = parentIsLib || (base !== '' && hasShelibMarker(dir));
|
|
40
|
+
const results = [];
|
|
41
|
+
for (const entry of entries) {
|
|
42
|
+
if (entry.name === '.shelib') continue;
|
|
43
|
+
const rel = base ? `${base}/${entry.name}` : entry.name;
|
|
44
|
+
if (entry.isDirectory()) {
|
|
45
|
+
results.push(...walk(path.join(dir, entry.name), rel, lib));
|
|
46
|
+
} else if (entry.name.endsWith('.js')) {
|
|
47
|
+
const stat = fs.statSync(path.join(dir, entry.name));
|
|
48
|
+
results.push({ path: rel, size: stat.size, mtime: stat.mtimeMs, lib });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return results;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Nested tree of all .js files and subdirectories.
|
|
56
|
+
* Each node: { type:'file'|'dir', name, path, lib, size?, mtime?, children? }
|
|
57
|
+
*/
|
|
58
|
+
function buildTree(dir, base, parentIsLib) {
|
|
59
|
+
let entries;
|
|
60
|
+
try {
|
|
61
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
62
|
+
} catch {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
const selfIsLib = base !== '' && hasShelibMarker(dir);
|
|
66
|
+
const lib = parentIsLib || selfIsLib;
|
|
67
|
+
const result = [];
|
|
68
|
+
for (const entry of entries) {
|
|
69
|
+
if (entry.name === '.shelib') continue;
|
|
70
|
+
const rel = base ? `${base}/${entry.name}` : entry.name;
|
|
71
|
+
const abs = path.join(dir, entry.name);
|
|
72
|
+
if (entry.isDirectory()) {
|
|
73
|
+
const childIsLib = lib || hasShelibMarker(abs);
|
|
74
|
+
const children = buildTree(abs, rel, childIsLib);
|
|
75
|
+
result.push({ type: 'dir', name: entry.name, path: rel, lib: childIsLib, children });
|
|
76
|
+
} else if (entry.name.endsWith('.js')) {
|
|
77
|
+
const stat = fs.statSync(abs);
|
|
78
|
+
result.push({ type: 'file', name: entry.name, path: rel, lib, size: stat.size, mtime: stat.mtimeMs });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
result.sort((a, b) => {
|
|
82
|
+
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
|
|
83
|
+
return a.name.localeCompare(b.name);
|
|
84
|
+
});
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// All routes dispatched via router.use to avoid path-to-regexp v8 wildcard issues.
|
|
89
|
+
router.use((req, res) => {
|
|
90
|
+
const root = getRoot(req);
|
|
91
|
+
if (!root) return res.status(503).json({ error: 'scriptDir not configured' });
|
|
92
|
+
|
|
93
|
+
const method = req.method.toUpperCase();
|
|
94
|
+
const filePath = req.path.replace(/^\/+/, '');
|
|
95
|
+
|
|
96
|
+
// GET /she/scripts — flat list of all .js files with metadata
|
|
97
|
+
if (method === 'GET' && !filePath) {
|
|
98
|
+
return res.json(walk(root, '', false));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// GET /she/scripts/tree — nested tree structure
|
|
102
|
+
if (method === 'GET' && filePath === 'tree') {
|
|
103
|
+
return res.json(buildTree(root, '', false));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// GET /she/scripts/<path> — read file content
|
|
107
|
+
if (method === 'GET') {
|
|
108
|
+
const abs = safePath(root, filePath);
|
|
109
|
+
if (!abs) return res.status(400).json({ error: 'Invalid path' });
|
|
110
|
+
try {
|
|
111
|
+
const content = fs.readFileSync(abs, 'utf8');
|
|
112
|
+
return res.json({ path: filePath, content });
|
|
113
|
+
} catch (err) {
|
|
114
|
+
if (err.code === 'ENOENT') return res.status(404).json({ error: 'Not found' });
|
|
115
|
+
return res.status(500).json({ error: err.message });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// PUT /she/scripts/<path> — create or overwrite file
|
|
120
|
+
if (method === 'PUT') {
|
|
121
|
+
const abs = safePath(root, filePath);
|
|
122
|
+
if (!abs) return res.status(400).json({ error: 'Invalid path' });
|
|
123
|
+
const basename = path.basename(abs);
|
|
124
|
+
if (!basename.endsWith('.js') && basename !== '.shelib') {
|
|
125
|
+
return res.status(400).json({ error: 'Only .js and .shelib files are allowed' });
|
|
126
|
+
}
|
|
127
|
+
const content = typeof req.body?.content === 'string' ? req.body.content : null;
|
|
128
|
+
if (content === null) return res.status(400).json({ error: 'Missing body.content string' });
|
|
129
|
+
try {
|
|
130
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
131
|
+
fs.writeFileSync(abs, content, 'utf8');
|
|
132
|
+
const stat = fs.statSync(abs);
|
|
133
|
+
return res.json({ ok: true, path: filePath, size: stat.size, mtime: stat.mtimeMs });
|
|
134
|
+
} catch (err) {
|
|
135
|
+
return res.status(500).json({ error: err.message });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// DELETE /she/scripts/<path> — delete file
|
|
140
|
+
if (method === 'DELETE') {
|
|
141
|
+
const abs = safePath(root, filePath);
|
|
142
|
+
if (!abs) return res.status(400).json({ error: 'Invalid path' });
|
|
143
|
+
try {
|
|
144
|
+
fs.unlinkSync(abs);
|
|
145
|
+
return res.json({ ok: true });
|
|
146
|
+
} catch (err) {
|
|
147
|
+
if (err.code === 'ENOENT') return res.status(404).json({ error: 'Not found' });
|
|
148
|
+
return res.status(500).json({ error: err.message });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// POST /she/scripts/mkdir — create a directory
|
|
153
|
+
if (method === 'POST' && filePath === 'mkdir') {
|
|
154
|
+
const dirPath = typeof req.body?.path === 'string' ? req.body.path : null;
|
|
155
|
+
if (!dirPath) return res.status(400).json({ error: 'Missing body.path string' });
|
|
156
|
+
const abs = safePath(root, dirPath);
|
|
157
|
+
if (!abs) return res.status(400).json({ error: 'Invalid path' });
|
|
158
|
+
try {
|
|
159
|
+
fs.mkdirSync(abs, { recursive: true });
|
|
160
|
+
return res.json({ ok: true, path: dirPath });
|
|
161
|
+
} catch (err) {
|
|
162
|
+
return res.status(500).json({ error: err.message });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// POST /she/scripts/<path>/rename — rename / move file
|
|
167
|
+
if (method === 'POST') {
|
|
168
|
+
if (!filePath.endsWith('/rename')) return res.status(404).json({ error: 'Not found' });
|
|
169
|
+
const srcPath = filePath.slice(0, -'/rename'.length);
|
|
170
|
+
const abs = safePath(root, srcPath);
|
|
171
|
+
if (!abs) return res.status(400).json({ error: 'Invalid path' });
|
|
172
|
+
const newName = req.body?.newPath;
|
|
173
|
+
if (typeof newName !== 'string' || !newName) {
|
|
174
|
+
return res.status(400).json({ error: 'Missing body.newPath string' });
|
|
175
|
+
}
|
|
176
|
+
const absNew = safePath(root, newName);
|
|
177
|
+
if (!absNew) return res.status(400).json({ error: 'Invalid newPath' });
|
|
178
|
+
if (!absNew.endsWith('.js')) return res.status(400).json({ error: 'Only .js files are allowed' });
|
|
179
|
+
try {
|
|
180
|
+
fs.mkdirSync(path.dirname(absNew), { recursive: true });
|
|
181
|
+
fs.renameSync(abs, absNew);
|
|
182
|
+
return res.json({ ok: true, path: newName });
|
|
183
|
+
} catch (err) {
|
|
184
|
+
if (err.code === 'ENOENT') return res.status(404).json({ error: 'Not found' });
|
|
185
|
+
return res.status(500).json({ error: err.message });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
res.status(405).json({ error: 'Method not allowed' });
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
module.exports = { router };
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const express = require('express');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { router: configRouter } = require('./config-api');
|
|
6
|
+
const { router: scriptsRouter } = require('./scripts-api');
|
|
7
|
+
const { router: shedbRouter } = require('./shedb-api');
|
|
8
|
+
const { router: matterRouter } = require('./matter-api');
|
|
9
|
+
const { router: mqttRouter } = require('./mqtt-api');
|
|
10
|
+
const { router: depsRouter } = require('./deps-api');
|
|
11
|
+
const { router: gitRouter } = require('./git-api');
|
|
12
|
+
const { router: aiRouter } = require('./ai-api');
|
|
13
|
+
const { attachWss, closeWss } = require('./log-ws');
|
|
14
|
+
const { init: initAuth, authMiddleware, checkAuth, router: authRouter } = require('./auth');
|
|
15
|
+
|
|
16
|
+
const app = express();
|
|
17
|
+
app.use(express.json());
|
|
18
|
+
|
|
19
|
+
// Public auth routes — always accessible regardless of auth mode.
|
|
20
|
+
// Must be mounted BEFORE the auth middleware.
|
|
21
|
+
app.use('/she/auth', authRouter);
|
|
22
|
+
|
|
23
|
+
// Auth middleware for all /she/* routes except the public auth endpoints above.
|
|
24
|
+
// /api/* is intentionally excluded — user scripts control their own auth.
|
|
25
|
+
const OPEN_SHE_PATHS = new Set(['/she/auth/mode', '/she/auth/login', '/she/auth/logout']);
|
|
26
|
+
app.use('/she', (req, res, next) => {
|
|
27
|
+
if (OPEN_SHE_PATHS.has(req.originalUrl.split('?')[0])) return next();
|
|
28
|
+
authMiddleware(req, res, next);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// Config REST endpoints: GET /she/config and PUT /she/config
|
|
32
|
+
app.use('/she/config', configRouter);
|
|
33
|
+
|
|
34
|
+
// Scripts file CRUD: GET/PUT/DELETE /she/scripts/*
|
|
35
|
+
app.use('/she/scripts', scriptsRouter);
|
|
36
|
+
|
|
37
|
+
// sheDB document store REST API: /she/db/*
|
|
38
|
+
app.use('/she/db', shedbRouter);
|
|
39
|
+
|
|
40
|
+
// Matter controller REST API: /she/matter/*
|
|
41
|
+
app.use('/she/matter', matterRouter);
|
|
42
|
+
|
|
43
|
+
// MQTT state snapshot and publish: /she/mqtt/*
|
|
44
|
+
app.use('/she/mqtt', mqttRouter);
|
|
45
|
+
|
|
46
|
+
// npm package management: /she/deps/*
|
|
47
|
+
app.use('/she/deps', depsRouter);
|
|
48
|
+
|
|
49
|
+
// Git integration: /she/git/*
|
|
50
|
+
app.use('/she/git', gitRouter);
|
|
51
|
+
|
|
52
|
+
// AI assistant proxy: /she/ai/*
|
|
53
|
+
app.use('/she/ai', aiRouter);
|
|
54
|
+
|
|
55
|
+
// Graceful daemon restart — exit(0) and let the process manager restart
|
|
56
|
+
app.post('/she/restart', (req, res) => {
|
|
57
|
+
res.json({ ok: true });
|
|
58
|
+
setTimeout(() => process.exit(0), 200);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Serve the built Svelte SPA from dist/web/
|
|
62
|
+
// Hashed assets (JS/CSS) are immutable; index.html must never be cached so
|
|
63
|
+
// browsers always pick up a freshly deployed version.
|
|
64
|
+
const distWeb = path.resolve(__dirname, '../../dist/web');
|
|
65
|
+
app.use(express.static(distWeb, { index: false })); // don't auto-serve index.html
|
|
66
|
+
// SPA fallback — serve index.html for any non-API route, always no-cache
|
|
67
|
+
app.use((req, res, next) => {
|
|
68
|
+
if (req.path.startsWith('/she') || req.path.startsWith('/api')) return next();
|
|
69
|
+
res.set('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
70
|
+
res.sendFile(path.join(distWeb, 'index.html'));
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Central route registry — key: 'METHOD /api/scriptname/path'
|
|
74
|
+
const registry = new Map();
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Register an HTTP route. Throws if the same method+path pair is already registered.
|
|
78
|
+
* @param {'get'|'post'|'put'|'delete'} method
|
|
79
|
+
* @param {string} fullPath - absolute Express path, e.g. '/api/myscript/foo'
|
|
80
|
+
* @param {Function} handler - Express route handler (req, res)
|
|
81
|
+
*/
|
|
82
|
+
function registerRoute(method, fullPath, handler) {
|
|
83
|
+
const key = `${method.toUpperCase()} ${fullPath}`;
|
|
84
|
+
if (registry.has(key)) {
|
|
85
|
+
throw new Error(`Route already registered: ${key}`);
|
|
86
|
+
}
|
|
87
|
+
registry.set(key, true);
|
|
88
|
+
app[method](fullPath, handler);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let httpServer = null;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Start listening. Resolves with the actual port (useful when port 0 is given).
|
|
95
|
+
* @param {number} port
|
|
96
|
+
* @param {{ auth?: string, password?: string, proxyHeader?: string, bindAddress?: string, configPath?: string, scriptDir?: string }} [options]
|
|
97
|
+
* @returns {Promise<number>}
|
|
98
|
+
*/
|
|
99
|
+
function startServer(port, options = {}) {
|
|
100
|
+
initAuth({
|
|
101
|
+
auth: options.auth || 'none',
|
|
102
|
+
password: options.password || null,
|
|
103
|
+
proxyHeader: options.proxyHeader || 'X-Remote-User',
|
|
104
|
+
configPath: options.configPath || null,
|
|
105
|
+
});
|
|
106
|
+
if (options.configPath) {
|
|
107
|
+
app.locals.configPath = options.configPath;
|
|
108
|
+
}
|
|
109
|
+
if (options.scriptDir) {
|
|
110
|
+
app.locals.scriptDir = options.scriptDir;
|
|
111
|
+
}
|
|
112
|
+
const host = options.bindAddress || '0.0.0.0';
|
|
113
|
+
return new Promise((resolve, reject) => {
|
|
114
|
+
httpServer = app.listen(port, host, () => {
|
|
115
|
+
attachWss(httpServer, checkAuth);
|
|
116
|
+
resolve(httpServer.address().port);
|
|
117
|
+
});
|
|
118
|
+
httpServer.on('error', reject);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Stop the HTTP server gracefully.
|
|
124
|
+
* @returns {Promise<void>}
|
|
125
|
+
*/
|
|
126
|
+
function stopServer() {
|
|
127
|
+
return new Promise((resolve) => {
|
|
128
|
+
if (httpServer) {
|
|
129
|
+
closeWss().then(() => {
|
|
130
|
+
httpServer.close(resolve);
|
|
131
|
+
httpServer = null;
|
|
132
|
+
});
|
|
133
|
+
} else {
|
|
134
|
+
resolve();
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
module.exports = { app, registerRoute, startServer, stopServer };
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* sheDB REST API — Express router mounted at /she/db
|
|
5
|
+
*
|
|
6
|
+
* Document IDs may contain slashes (MQTT-topic style), so all document and
|
|
7
|
+
* view routes use router.use() to read req.path directly instead of named
|
|
8
|
+
* params, avoiding path-to-regexp v8 wildcard incompatibilities with Express 5.
|
|
9
|
+
*
|
|
10
|
+
* Routes:
|
|
11
|
+
* GET /she/db/docs → sorted list of document IDs
|
|
12
|
+
* GET /she/db/docs/<id> → get document
|
|
13
|
+
* PUT /she/db/docs/<id> → set document (full overwrite)
|
|
14
|
+
* PATCH /she/db/docs/<id> → extend document (deep merge)
|
|
15
|
+
* DELETE /she/db/docs/<id> → delete document
|
|
16
|
+
*
|
|
17
|
+
* GET /she/db/views → sorted list of view IDs
|
|
18
|
+
* GET /she/db/views/<id> → get view definition { filter, map, reduce }
|
|
19
|
+
* PUT /she/db/views/<id> → create/update view
|
|
20
|
+
* GET /she/db/views/<id>/result → get computed view result
|
|
21
|
+
* DELETE /she/db/views/<id> → delete view
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const express = require('express');
|
|
25
|
+
const { getCore } = require('./shedb');
|
|
26
|
+
|
|
27
|
+
const router = express.Router();
|
|
28
|
+
|
|
29
|
+
function core503(res) {
|
|
30
|
+
return res.status(503).json({ error: 'shedb not initialised' });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// /she/db/docs — document CRUD
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
router.use('/docs', (req, res) => {
|
|
38
|
+
const core = getCore();
|
|
39
|
+
if (!core) return core503(res);
|
|
40
|
+
|
|
41
|
+
const method = req.method.toUpperCase();
|
|
42
|
+
// Strip leading slash; empty → list all
|
|
43
|
+
const id = req.path.replace(/^\/+/, '');
|
|
44
|
+
|
|
45
|
+
// GET /she/db/docs — list all IDs
|
|
46
|
+
if (method === 'GET' && !id) {
|
|
47
|
+
return res.json(Object.keys(core.docs).sort());
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// GET /she/db/docs/<id>
|
|
51
|
+
if (method === 'GET') {
|
|
52
|
+
const doc = core.get(id);
|
|
53
|
+
return doc ? res.json(doc) : res.status(404).json({ error: 'not found' });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// PUT /she/db/docs/<id>
|
|
57
|
+
if (method === 'PUT') {
|
|
58
|
+
if (!id) return res.status(400).json({ error: 'id required' });
|
|
59
|
+
if (!req.body || typeof req.body !== 'object') return res.status(400).json({ error: 'body must be a JSON object' });
|
|
60
|
+
core.set(id, req.body);
|
|
61
|
+
return res.json({ ok: true });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// PATCH /she/db/docs/<id> — deep merge
|
|
65
|
+
if (method === 'PATCH') {
|
|
66
|
+
if (!id) return res.status(400).json({ error: 'id required' });
|
|
67
|
+
if (!req.body || typeof req.body !== 'object') return res.status(400).json({ error: 'body must be a JSON object' });
|
|
68
|
+
core.extend(id, req.body);
|
|
69
|
+
return res.json({ ok: true });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// DELETE /she/db/docs/<id>
|
|
73
|
+
if (method === 'DELETE') {
|
|
74
|
+
if (!id) return res.status(400).json({ error: 'id required' });
|
|
75
|
+
core.del(id);
|
|
76
|
+
return res.json({ ok: true });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
res.status(405).json({ error: 'method not allowed' });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// /she/db/views — named view CRUD
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
router.use('/views', (req, res) => {
|
|
87
|
+
const core = getCore();
|
|
88
|
+
if (!core) return core503(res);
|
|
89
|
+
|
|
90
|
+
const method = req.method.toUpperCase();
|
|
91
|
+
// Strip leading slash; detect /result suffix
|
|
92
|
+
const rawPath = req.path.replace(/^\/+/, '');
|
|
93
|
+
const isResult = rawPath.endsWith('/result');
|
|
94
|
+
const id = isResult ? rawPath.slice(0, -'/result'.length) : rawPath;
|
|
95
|
+
|
|
96
|
+
// GET /she/db/views — list all view IDs
|
|
97
|
+
if (method === 'GET' && !rawPath) {
|
|
98
|
+
return res.json(Object.keys(core.queries).sort());
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// GET /she/db/views/<id>/result
|
|
102
|
+
if (method === 'GET' && isResult) {
|
|
103
|
+
if (!id) return res.status(400).json({ error: 'id required' });
|
|
104
|
+
const view = core.views[id];
|
|
105
|
+
return view ? res.json(view) : res.status(404).json({ error: 'not found' });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// GET /she/db/views/<id> — definition
|
|
109
|
+
if (method === 'GET') {
|
|
110
|
+
const query = core.queries[id];
|
|
111
|
+
return query ? res.json(query) : res.status(404).json({ error: 'not found' });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// PUT /she/db/views/<id> — create/update view
|
|
115
|
+
if (method === 'PUT') {
|
|
116
|
+
if (!id || isResult) return res.status(400).json({ error: 'id required' });
|
|
117
|
+
const { filter, map, reduce, mqttpub, retain } = req.body || {};
|
|
118
|
+
if (typeof map !== 'string' || !map.trim()) return res.status(400).json({ error: '"map" function string is required' });
|
|
119
|
+
const payload = {
|
|
120
|
+
filter: filter || undefined,
|
|
121
|
+
map,
|
|
122
|
+
reduce: reduce || undefined,
|
|
123
|
+
...(mqttpub ? { mqttpub: true } : {}),
|
|
124
|
+
...(retain ? { retain: true } : {}),
|
|
125
|
+
};
|
|
126
|
+
core.query(id, payload);
|
|
127
|
+
return res.json({ ok: true });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// DELETE /she/db/views/<id>
|
|
131
|
+
if (method === 'DELETE') {
|
|
132
|
+
if (!id || isResult) return res.status(400).json({ error: 'id required' });
|
|
133
|
+
core.query(id, '');
|
|
134
|
+
return res.json({ ok: true });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
res.status(405).json({ error: 'method not allowed' });
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
module.exports = { router };
|
package/src/web/shedb.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* sheDB integration module.
|
|
5
|
+
*
|
|
6
|
+
* Wraps SheDBCore and provides:
|
|
7
|
+
* - MQTT command topic handling (she/db/set/#, extend/#, delete/#, prop/#, get/#)
|
|
8
|
+
* - WebSocket broadcast of document/view change events
|
|
9
|
+
* - Listener registry for she.db.sub() sandbox subscriptions
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const SheDBCore = require('../lib/shedb-core');
|
|
13
|
+
const mqttWildcard = require('../lib/mqtt-wildcards');
|
|
14
|
+
|
|
15
|
+
let _core = null;
|
|
16
|
+
let _mqtt = null;
|
|
17
|
+
let _mqttName = '';
|
|
18
|
+
let _dbPublish = false;
|
|
19
|
+
let _dbRetain = false;
|
|
20
|
+
let _dbPrefix = 'she/db/';
|
|
21
|
+
let _broadcast = () => {};
|
|
22
|
+
|
|
23
|
+
/** Registry for she.db.sub() sandbox subscriptions */
|
|
24
|
+
const _listeners = []; // { pattern: string, callback: Function, _script: string }
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Initialise sheDB.
|
|
28
|
+
*
|
|
29
|
+
* @param {object} opts
|
|
30
|
+
* @param {string} opts.dbPath - absolute path to the JSON data file
|
|
31
|
+
* @param {boolean} opts.dbRetain - whether to publish docs/views as retained MQTT messages
|
|
32
|
+
* @param {string} opts.mqttName - mqtt client name / topic prefix (config.name)
|
|
33
|
+
* @param {object} opts.mqtt - connected mqtt.js client (may be null if offline)
|
|
34
|
+
* @param {object} opts.log - pino-compatible logger
|
|
35
|
+
* @param {Function} opts.broadcast - function(msg) to push a message to all WS clients
|
|
36
|
+
* @returns {SheDBCore}
|
|
37
|
+
*/
|
|
38
|
+
function init({ dbPath, dbPublish, dbRetain, dbPrefix, mqttName, mqtt, log, broadcast }) {
|
|
39
|
+
_mqtt = mqtt;
|
|
40
|
+
_mqttName = mqttName;
|
|
41
|
+
_dbPublish = dbPublish;
|
|
42
|
+
_dbRetain = dbRetain;
|
|
43
|
+
_dbPrefix = (dbPrefix && dbPrefix.endsWith('/')) ? dbPrefix : (dbPrefix || 'she/db/') + '/';
|
|
44
|
+
_broadcast = broadcast;
|
|
45
|
+
|
|
46
|
+
_core = new SheDBCore({ dbPath, log });
|
|
47
|
+
|
|
48
|
+
_core.on('ready', () => {
|
|
49
|
+
log.info('shedb ready, ' + Object.keys(_core.docs).length + ' docs, ' + Object.keys(_core.queries).length + ' views');
|
|
50
|
+
_broadcast({ type: 'db:ids', ids: Object.keys(_core.docs).sort() });
|
|
51
|
+
_broadcast({ type: 'db:viewIds', ids: Object.keys(_core.queries).sort() });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
_core.on('update', (id, doc) => {
|
|
55
|
+
_broadcast({ type: 'db:change', id, doc: doc || null });
|
|
56
|
+
_broadcast({ type: 'db:ids', ids: Object.keys(_core.docs).sort() });
|
|
57
|
+
|
|
58
|
+
if (_dbPublish && _mqtt) {
|
|
59
|
+
_mqtt.publish(_dbPrefix + 'doc/' + id, doc ? JSON.stringify(doc) : '', { retain: _dbRetain });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Fire sandbox she.db.sub() listeners
|
|
63
|
+
for (const sub of _listeners) {
|
|
64
|
+
if (mqttWildcard(id, sub.pattern) !== null) {
|
|
65
|
+
try {
|
|
66
|
+
sub.callback(id, doc || null);
|
|
67
|
+
} catch {
|
|
68
|
+
/* errors are caught by the script domain wrapper */
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
_core.on('view', (id, view) => {
|
|
75
|
+
_broadcast({ type: 'db:viewUpdate', id });
|
|
76
|
+
_broadcast({ type: 'db:viewIds', ids: Object.keys(_core.queries).sort() });
|
|
77
|
+
|
|
78
|
+
// Per-view MQTT publish (independent of global dbPublish setting)
|
|
79
|
+
const query = _core.queries[id];
|
|
80
|
+
if (query && query.mqttpub && _mqtt && view && !view.error) {
|
|
81
|
+
_mqtt.publish(
|
|
82
|
+
_dbPrefix + 'view/' + id,
|
|
83
|
+
JSON.stringify(view.result ?? []),
|
|
84
|
+
{ retain: query.retain === true }
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
return _core;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Route an incoming MQTT message to sheDB if it matches the db topic prefix.
|
|
94
|
+
* Called from the main mqtt.on('message') handler.
|
|
95
|
+
*
|
|
96
|
+
* @param {string} topic
|
|
97
|
+
* @param {Buffer} payload
|
|
98
|
+
* @returns {boolean} true if the message was handled by sheDB
|
|
99
|
+
*/
|
|
100
|
+
function handleMqttMessage(topic, payload) {
|
|
101
|
+
if (!_core) return false;
|
|
102
|
+
|
|
103
|
+
const prefix = _dbPrefix;
|
|
104
|
+
if (!topic.startsWith(prefix)) return false;
|
|
105
|
+
|
|
106
|
+
const rest = topic.slice(prefix.length);
|
|
107
|
+
const slash = rest.indexOf('/');
|
|
108
|
+
if (slash === -1) return false;
|
|
109
|
+
|
|
110
|
+
const cmd = rest.slice(0, slash);
|
|
111
|
+
const id = rest.slice(slash + 1);
|
|
112
|
+
const str = payload.toString();
|
|
113
|
+
|
|
114
|
+
switch (cmd) {
|
|
115
|
+
case 'set':
|
|
116
|
+
try {
|
|
117
|
+
_core.set(id, JSON.parse(str));
|
|
118
|
+
} catch {
|
|
119
|
+
/* ignore malformed payload */
|
|
120
|
+
}
|
|
121
|
+
break;
|
|
122
|
+
case 'extend':
|
|
123
|
+
try {
|
|
124
|
+
_core.extend(id, JSON.parse(str));
|
|
125
|
+
} catch {
|
|
126
|
+
/* ignore malformed payload */
|
|
127
|
+
}
|
|
128
|
+
break;
|
|
129
|
+
case 'delete':
|
|
130
|
+
_core.del(id);
|
|
131
|
+
break;
|
|
132
|
+
case 'prop':
|
|
133
|
+
try {
|
|
134
|
+
_core.prop(id, JSON.parse(str));
|
|
135
|
+
} catch {
|
|
136
|
+
/* ignore malformed payload */
|
|
137
|
+
}
|
|
138
|
+
break;
|
|
139
|
+
case 'get':
|
|
140
|
+
if (_mqtt) {
|
|
141
|
+
const doc = _core.get(id);
|
|
142
|
+
_mqtt.publish(_dbPrefix + 'result/' + id, doc ? JSON.stringify(doc) : '');
|
|
143
|
+
}
|
|
144
|
+
break;
|
|
145
|
+
default:
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Register a she.db.sub() listener. */
|
|
152
|
+
function addListener(pattern, callback, scriptName) {
|
|
153
|
+
_listeners.push({ pattern, callback, _script: scriptName });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Remove all listeners registered by a script (called on hot-reload / unload). */
|
|
157
|
+
function removeListenersByScript(scriptName) {
|
|
158
|
+
for (let i = _listeners.length - 1; i >= 0; i--) {
|
|
159
|
+
if (_listeners[i]._script === scriptName) _listeners.splice(i, 1);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Return the live core instance (used by shedb-api.js). */
|
|
164
|
+
function getCore() {
|
|
165
|
+
return _core;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
module.exports = { init, handleMqttMessage, addListener, removeListenersByScript, getCore };
|
package/index.js
DELETED
|
File without changes
|