Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
55698eb
rules editor: TypeScript support with in-browser language service
evgeny-boger Aug 14, 2026
91503dd
rules editor: controller ts-check banner + review fixes
evgeny-boger Aug 14, 2026
c7d0b05
rules editor: don't double-escape controller check messages
evgeny-boger Aug 14, 2026
71e0a3c
rules editor: show controller type-check verdict inline, not as a banner
evgeny-boger Aug 14, 2026
098422e
rules editor: de-duplicate controller diagnostics against the local c…
evgeny-boger Aug 14, 2026
963ec1a
rules editor: pull controller verdict via Editor.Check RPC; seed type…
evgeny-boger Aug 14, 2026
b3b5dce
rules editor: suppress stale controller diagnostics once the user edits
evgeny-boger Aug 14, 2026
a201174
rules editor: adapt to Editor.Check statuses; fix review findings
evgeny-boger Aug 14, 2026
4e75e58
rules editor: fix from-scratch review findings (H1-H3, M1-M4, L1-L7)
evgeny-boger Aug 14, 2026
dcfa554
rules editor: run the language service for .js rules too
evgeny-boger Aug 14, 2026
42b0a56
rules editor: CI-safe timeout for language-service cold-start tests
evgeny-boger Aug 14, 2026
33c7a3c
debian: 2.246.0 - TypeScript rules editor
evgeny-boger Aug 14, 2026
0f9a74c
rules editor: fix review findings from the from-scratch PR #1202 review
evgeny-boger Aug 15, 2026
8ba83ff
rules editor: async API types - promise-returning spawn/runShellComma…
evgeny-boger Aug 15, 2026
b5ad707
rules editor: rename delay() to sleep() in wb-rules declarations
evgeny-boger Aug 15, 2026
5f6b292
rules editor: declare changed() - awaitable whenChanged
evgeny-boger Aug 15, 2026
1a2e8e9
debian: pair the TS rules editor with the TS-capable wb-rules
evgeny-boger Aug 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions debian/changelog
Original file line number Diff line number Diff line change
@@ -1,3 +1,20 @@
wb-mqtt-homeui (2.246.0) stable; urgency=medium

* Rules editor: TypeScript support. .ts rule files can be created and
edited with an in-browser TypeScript language service: live error
squiggles while typing, hover type info and type-aware completions,
seeded with the controller's installed wb-rules API declarations
(Editor.GetTypes RPC, vendored fallback for older firmware).
* Plain .js rules use the same language service (allowJs): completions
and hover reflect the running engine's API; no type-error noise.
* The controller's own background check verdict (Editor.Check RPC) is
rendered inline at the reported lines, de-duplicated against the
local check and suppressed while the buffer has unsaved edits.
* Requires wb-rules >= 2.47 for the TypeScript engine support; the
editor degrades gracefully on older firmware.

-- Evgeny Boger <boger@wirenboard.com> Fri, 14 Aug 2026 22:00:00 +0000

wb-mqtt-homeui (2.245.10) stable; urgency=medium

* Fix create first user logic
Expand Down
2 changes: 1 addition & 1 deletion debian/control
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Depends: ${shlibs:Depends},
wb-homeui-backend (= ${binary:Version})
Recommends: wb-mqtt-logs (>= 1.2.0),
wb-device-manager,
wb-rules (>= 2.37.0~~)
wb-rules (>= 2.47.0~quickjs3~~)
Suggests: wb-mqtt-confed (>= 1.4.0),
Breaks: wb-mqtt-confed (<< 1.0.3),
wb-mqtt-db (<< 1.5),
Expand Down
5 changes: 5 additions & 0 deletions frontend/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ const getCustomConfig = (cfg) => {
const customIgnores = [
'src/custom.d.ts',
'src/components/json-editor/extensions/*',
// vendored wb-rules engine declarations (raw-imported for the TS
// language service, not app code) and the completion list generated
// from them
'src/stores/rules/autocomplete/wb-rules.d.ts',
'src/stores/rules/autocomplete/globals-generated.ts',
];
const { ignores, ...rest } = cfg.at(0);

Expand Down
33 changes: 31 additions & 2 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 6 additions & 2 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"test:watch": "vitest",
"lint": "npx eslint --cache --cache-location .eslintcache",
"lint:fix": "npx eslint --fix",
"check:types": "tsc --noEmit"
"check:types": "tsc --noEmit",
"generate:completions": "node scripts/generate-wb-rules-completions.mjs"
},
"repository": {
"type": "git",
Expand All @@ -25,6 +26,7 @@
"dependencies": {
"@codemirror/lang-javascript": "6.2.5",
"@codemirror/lang-json": "6.0.2",
"@codemirror/lint": "6.9.6",
"@codemirror/state": "6.6.0",
"@codemirror/view": "6.43.0",
"@daypicker/react": "10.0.1",
Expand All @@ -34,7 +36,9 @@
"@dnd-kit/utilities": "3.2.2",
"@floating-ui/react": "0.27.19",
"@rpldy/uploady": "1.13.0",
"@typescript/vfs": "1.6.4",
"@uiw/react-codemirror": "4.25.10",
"@valtown/codemirror-ts": "2.3.1",
"@wirenboard/json-editor": "2.5.3-wb19",
"@xterm/addon-fit": "0.11.0",
"@xterm/addon-web-links": "0.12.0",
Expand Down Expand Up @@ -66,6 +70,7 @@
"react-select": "5.10.2",
"react-sortablejs": "6.1.4",
"sortablejs": "1.15.7",
"typescript": "6.0.3",
"use-file-picker": "2.1.4",
"xterm": "5.3.0"
},
Expand All @@ -87,7 +92,6 @@
"globals": "17.6.0",
"happy-dom": "20.9.0",
"rimraf": "6.1.3",
"typescript": "6.0.3",
"use-resize-observer": "9.1.0",
"vite": "8.0.13",
"vite-plugin-svgr": "5.2.0",
Expand Down
80 changes: 80 additions & 0 deletions frontend/scripts/generate-wb-rules-completions.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Generates autocomplete/globals-generated.ts from autocomplete/wb-rules.d.ts.
// The .d.ts (synced from wb-rules types/wb-rules.d.ts) is the single source
// of truth for the builtin API; run `npm run generate:completions` after
// updating it.
import fs from 'node:fs';
import path from 'node:path';
import url from 'node:url';
import ts from 'typescript';

// single-quoted string literal matching the repo eslint style, so
// regenerating never dirties the tree
const q = (s) => `'${String(s).replace(/\\/g, '\\\\').replace(/'/g, '\\\'')}'`;

const here = path.dirname(url.fileURLToPath(import.meta.url));
const dtsPath = path.join(here, '../src/stores/rules/autocomplete/wb-rules.d.ts');
const outPath = path.join(here, '../src/stores/rules/autocomplete/globals-generated.ts');

const source = ts.createSourceFile('wb-rules.d.ts', fs.readFileSync(dtsPath, 'utf8'), ts.ScriptTarget.Latest);
const printer = ts.createPrinter({ removeComments: true });

const seen = new Set();
const completions = [];

const signatureOf = (node) => {
const text = printer.printNode(ts.EmitHint.Unspecified, node, source)
.replace(/^declare\s+/, '')
.replace(/\s+/g, ' ')
.trim();
return text.length > 60 ? `${text.slice(0, 57)}...` : text;
};

const snippetFor = (name, params) => {
if (params.length === 0) return `${name}()`;
const args = params
.filter((p) => !p.questionToken && !p.dotDotDotToken)
.map((p, i) => `\${${i + 1}:${p.name.getText(source)}}`);
return `${name}(${args.join(', ')})`;
};

for (const stmt of source.statements) {
if (ts.isFunctionDeclaration(stmt) && stmt.name) {
const name = stmt.name.text;
if (seen.has(name)) continue; // keep the first overload only
seen.add(name);
completions.push({
label: name,
type: 'function',
detail: signatureOf(stmt),
snippet: snippetFor(name, stmt.parameters),
});
} else if (ts.isVariableStatement(stmt)) {
for (const decl of stmt.declarationList.declarations) {
const name = decl.name.getText(source);
if (seen.has(name)) continue;
seen.add(name);
completions.push({
label: name,
type: 'variable',
detail: decl.type ? signatureOf(decl.type) : '',
});
}
}
}

const body = completions.map((c) => {
const detail = q(c.detail);
return c.snippet && c.snippet !== `${c.label}()`
? ` snippetCompletion(${q(c.snippet)}, { label: ${q(c.label)}, type: '${c.type}', detail: ${detail} }),`
: ` { label: ${q(c.label)}, type: '${c.type}', detail: ${detail}${c.snippet ? `, apply: ${q(c.snippet)}` : ''} },`;
}).join('\n');

fs.writeFileSync(outPath, `// GENERATED from wb-rules.d.ts — do not edit by hand.
// Regenerate with: npm run generate:completions
import { snippetCompletion, type Completion } from '@codemirror/autocomplete';

export const wbRulesGlobals: Completion[] = [
${body}
];
`);
console.log(`generated ${completions.length} completions -> ${outPath}`);
16 changes: 16 additions & 0 deletions frontend/src/custom.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,19 @@ declare const __LOGO__: string;
declare const __LOGO_COMPACT__: string;
declare const __APP_NAME__: string;
declare const __APP_SHORT_NAME__: string;

// raw-text imports (vite ?raw suffix) used by the TS language service
declare module '*?raw' {
const text: string;
export default text;
}

// import.meta.glob is provided by vite; the project compiles with
// types: ["vitest/globals"] instead of vite/client, so declare the
// subset we use
interface ImportMeta {
glob(
pattern: string,
options: { query: string; import: string; eager: true }
): Record<string, string>;
}
3 changes: 3 additions & 0 deletions frontend/src/pages/rules/[rule]/edit-rule.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ const { rulesMock, navigateMock, paramsMock, setIsDirtyMock } = vi.hoisted(() =>
setRule: vi.fn(),
setRuleName: vi.fn(),
checkIsNameUnique: vi.fn(async () => true),
tsCheckDiags: [],
checkTsFile: vi.fn(async () => {}),
clearTsCheck: vi.fn(),
},
navigateMock: vi.fn(),
paramsMock: { id: 'test-rule.js' } as Record<string, string | undefined>,
Expand Down
86 changes: 85 additions & 1 deletion frontend/src/pages/rules/[rule]/edit-rule.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,13 @@ import { Button } from '@/components/button';
import { CodeEditor } from '@/components/code-editor';
import { Tag } from '@/components/tag';
import { PageLayout } from '@/layouts/page';
import { editorProxy } from '@/services';
import { authStore, UserRole } from '@/stores/auth';
import { devicesStore } from '@/stores/devices';
import { rulesStore } from '@/stores/rules';
import { getExtensions } from '@/stores/rules/autocomplete';
import { controllerDiagnostics } from '@/stores/rules/autocomplete/controller-diagnostics';
import type { TsEditorSupport } from '@/stores/rules/autocomplete/types';
import { useAsyncAction } from '@/utils/async-action';
import { usePreventLeavePage } from '@/utils/prevent-page-leave';
import './styles.css';
Expand All @@ -24,6 +27,83 @@ const EditRulePage = observer(() => {
const params = useParams();
const navigate = useNavigate();
const [isEditingTitle, setIsEditingTitle] = useState(!params['*']);
const ruleFileName = params['*'] || rule.name || '';
const isTypeScript = ruleFileName.endsWith('.ts');
const [tsSupport, setTsSupport] = useState<TsEditorSupport | null>(null);
// for an unsaved rule the title changes on every keystroke; the service
// is keyed on a stable placeholder path instead, so typing a name does
// not re-run the effect below (a GetTypes RPC + environment rebuild each)
const servicePath = params['*'] || (isTypeScript ? 'unsaved.ts' : 'unsaved.js');

useEffect(() => {
// the language service runs for .js rules too (allowJs): completions
// and hover reflect the controller's installed API via GetTypes
// instead of the build-time snapshot; checkJs stays off, so wild ES5
// gets no type-error noise
if (isLoading) {
setTsSupport(null);
return undefined;
}
let alive = true;
// the language service (typescript + lib files, ~1 MB gzip) stays in
// a lazy chunk, loaded only once a rule editor opens.
// Types come from the controller (Editor.GetTypes) so the editor
// validates against the installed engine's API; the vendored copy is
// only the offline fallback.
// race the controller types against a short deadline: old firmware
// without Editor.GetTypes would otherwise stall TS support for the
// full 60s RPC timeout before the vendored fallback kicks in
const controllerTypes = Promise.race([
editorProxy.GetTypes().then((r) => r?.content, () => undefined),
new Promise<undefined>((resolve) => setTimeout(() => resolve(undefined), 3000)),
]);
Promise.all([
import('@/stores/rules/autocomplete/ts-language-service'),
controllerTypes,
])
.then(([m, typesDts]) => m.loadTsEditorSupport(servicePath, rule.content, typesDts))
.then(
(support) => alive && setTsSupport(support),
() => {}, // editor still works without the language service
);
return () => {
alive = false;
};
// rule.content is deliberately not a dependency: it only seeds the
// language service; tsSync() tracks all further edits
}, [servicePath, isLoading]);

useEffect(() => {
rulesStore.clearTsCheck();
if (isTypeScript && params['*'] && !isLoading) {
rulesStore.checkTsFile(params['*']);
}
// cancel the poll loop when leaving the page
return () => rulesStore.clearTsCheck();
}, [isTypeScript, params['*'], isLoading]);

// rebuilt only when the language service (re)loads: a fresh extensions
// array per keystroke would reconfigure CodeMirror and re-run every
// lint source synchronously on each character typed
const editorExtensions = useMemo(() => [
...getExtensions(devicesStore, {
typescript: isTypeScript,
typeAwareSource: tsSupport?.completionSource,
}),
...(tsSupport?.extensions ?? []),
// controller-side tsgo verdict, inline at the reported lines
...(isTypeScript
? [
controllerDiagnostics(
() => ({
diags: rulesStore.tsCheckDiags,
checkedContent: rulesStore.tsCheckedContent,
}),
() => tsSupport?.getDiagnostics() ?? [],
),
]
: []),
], [isTypeScript, tsSupport]);

const errors = useMemo(() => {
if (pageLoadError) {
Expand Down Expand Up @@ -60,8 +140,12 @@ const EditRulePage = observer(() => {
await rulesStore.checkIsNameUnique(rule.name);
}
try {
const savedContent = rule.content;
const savedRuleName = await rulesStore.save(rule);
setIsDirty(false);
if (savedRuleName.endsWith('.ts')) {
rulesStore.checkTsFile(savedRuleName, savedContent);
}
if (!params['*']) {
const encoded = savedRuleName.split('/').map(encodeURIComponent).join('/');
return navigate(`/rules/${encoded}`, { replace: true });
Expand Down Expand Up @@ -105,7 +189,7 @@ const EditRulePage = observer(() => {
text={rule.content}
errorLines={rule.error?.errorLine ? [rule.error.errorLine] : null}
autoFocus={!!params['*']}
extensions={getExtensions(devicesStore)}
extensions={editorExtensions}
onChange={(value) => {
setIsDirty(true);
rulesStore.setRule(value);
Expand Down
Loading