Building Production Shelly Scripts with Modern JavaScript
Introduction
Shelly devices are one of my favorites IoT devices thanks to their built-in JavaScript support , but unless I’ve missed something, the developer experience is pretty poor :
- Write ES5 syntax (no
const,let, arrow functions) - No modules or imports
- Trial-and-error testing on the device
- No type safety
- Manual minification if you hit the 25KB limit
I played a bit with rollup to find out if I could improve a bit, and while my setup feels still like wip, it’s already alleviating the pain significantly enough (using es6+, type safety, tests) that I thought this could be nice to document (note I used an llm to produce this, so it’s way more verbose than what I’d produce normally)
What we’re working with
Shelly Gen2 devices run a modified Espruino interpreter with hard limits:
- 25KB total script size (shared between code and runtime memory)
- 5 timers maximum
- ES5 syntax only (targets ~2009 JavaScript)
- No npm packages (can’t use most libraries)
So yeah, the traditional approach is to write ES5 directly, paste it in the web UI, test on device, repeat. That works for small scripts, but it gets painful fast.
How the build pipeline works
Here’s what happens when I run npm run build:
Modern JavaScript (src/)
↓
Type Check (JSDoc + TypeScript compiler)
↓
Lint (jshint)
↓
Test (Node.js built-in test runner)
↓
Bundle (Rollup - resolve imports, tree-shake)
↓
Transpile (SWC - ES6+ → ES5)
↓
Minify (SWC - production build)
↓
Deploy (6-8KB script to device)
Rollup bundles everything into a single file and removes unused code (tree-shaking). Builds take ~100ms.
SWC handles both transpiling (ES6+ → ES5) and minification. It’s 20x faster than Babel and good enough for this.
JSDoc for types instead of TypeScript - I can write vanilla JavaScript and still get type checking. The TypeScript compiler checks JSDoc comments without compiling anything. Less tooling, good enough.
Project Structure
src/
├── power-monitor/ # Reusable monitoring strategies
│ ├── runDurationCheckStrategy.js
│ ├── runFrequencyCheckStrategy.js
│ ├── powerSignatureCheckStrategy.js
│ └── activityExporter.js
├── alert/ # Alert implementations
│ ├── compositeAlertStrategy.js
│ ├── pagerdutyAlertStrategy.js
│ └── mqttAlertStrategy.js
├── common/ # Shared utilities
│ ├── logger.js
│ └── utils.js
├── ost/ # Location-specific deployments
│ └── basement-pumps/
│ ├── main.js # Entry point
│ └── config.js # Device-specific config
└── types/ # TypeScript definitions
├── shelly.d.ts # Shelly device APIs
└── types.d.ts # Application types
dist/
└── ost/
└── basement-pumps/
├── basement-pumps-v0.0.54.js # Dev build (19KB)
└── basement-pumps-v0.0.54.min.js # Production (8KB)
I organize by physical location (ost/ is somewhere, and furu/ somewhere else). Monitoring strategies like runDurationCheckStrategy.js are reusable across devices - same code could works for different pumps, just different config.
Config files
rollup.config.js (build setup)
import resolve from '@rollup/plugin-node-resolve';
import { swc } from 'rollup-plugin-swc3';
import replace from '@rollup/plugin-replace';
const baseConfig = (input, minify = false) => ({
input: input,
output: {
file: `dist/${name}.${minify ? 'min.' : ''}js`,
format: 'cjs',
strict: false, // Shelly doesn't support strict mode
},
plugins: [
resolve(), // Resolve node_modules imports
swc({
jsc: {
parser: { syntax: "ecmascript" },
minify: minify ? {
compress: { unused: true },
mangle: true
} : {},
target: 'es5', // Critical: ES5 for Espruino!
},
}),
replace({
values: {
'process.env.NODE_ENV': JSON.stringify(
minify ? 'production' : 'development'
),
},
preventAssignment: true,
}),
],
treeshake: true, // Remove unused code
});
export default [
baseConfig('src/ost/basement-pumps/main.js', false), // Dev
baseConfig('src/ost/basement-pumps/main.js', true), // Prod
];
package.json Scripts
{
"scripts": {
"tsc": "tsc", // Type check with TypeScript
"lint": "jshint src", // Lint JavaScript
"test": "node --test", // Run tests
"prebuild": "npm run lint && npm run tsc && npm run test",
"build": "rollup -c" // Bundle and minify
}
}
Type Checking with JSDoc
Instead of TypeScript files, we use JSDoc comments:
/**
* @param {RunDurationCheckConfig} config
* @param {Logger} logger
* @param {AlertStrategy} alertStrategy
* @return {RunDurationCheckStrategy}
*/
export function buildRunDurationCheckStrategy(config, logger, alertStrategy) {
// TypeScript checks this!
return {
handler: function(event) { /* ... */ },
periodicChecker: function() { /* ... */ },
run: function() { /* ... */ }
};
}
Type definitions in src/types/types.d.ts:
declare interface RunDurationCheckConfig {
device: string;
maxRunDuration: number;
pwThreshold: number;
eventFilter: (event: any) => boolean;
}
declare interface RunDurationCheckStrategy {
handler: (event: any, user_data: any) => void;
periodicChecker: () => void;
run: () => void;
}
The TypeScript compiler checks all this without compiling - just validation.
Writing modular code
You write normal ES6 imports:
// src/alert/pagerdutyAlertStrategy.js
export default {
init: function() { /* ... */ },
alert: function(message) { /* ... */ }
};
// src/ost/basement-pumps/main.js
import pagerDutyAlertStrategy from '../../alert/pagerdutyAlertStrategy.js';
import { buildRunDurationCheckStrategy } from '../../power-monitor/runDurationCheckStrategy.js';
// Use imports normally
compositeAlertStrategy.addStrategy(pagerDutyAlertStrategy);
let strategy = buildRunDurationCheckStrategy(config, logger, alertStrategy);
Rollup bundles all imports into a single file, converts to ES5, and you get something like:
var pagerDutyAlertStrategy = { init: function() { /* ... */ } };
function buildRunDurationCheckStrategy(config, logger, alertStrategy) { /* ... */ }
compositeAlertStrategy.addStrategy(pagerDutyAlertStrategy);
var strategy = buildRunDurationCheckStrategy(config, logger, alertStrategy);
One file, ES5 syntax, ready to paste into Shelly.
How well does this work?
My basement pump monitoring script builds to:
| Build Type | Size | % of 25KB Budget |
|---|---|---|
| Source (ES6+) | 19,663 bytes | 79% |
| Dev build (ES5) | 19,663 bytes | 79% |
| Production (minified) | 8,141 bytes | 33% ✅ |
3.2x compression ratio. Not bad.
What gets smaller
Variable names:
// Source:
let currentWindowActiveTime = 0;
let previousWindowActiveTime = 0;
// Minified:
var i=0,o=0;
Debug logging removed:
// Source:
logger.debug("Pump started: " + power + "W");
// Production (logger.debug → noop):
// (completely removed by dead code elimination)
Function inlining:
// Source (multiple files):
import logger from './logger.js';
logger.info("Starting");
// Minified:
print("Starting"); // Inlined to Shelly's print()
What this gets you
Local development - Write code in vim, type checking catches errors before deploy, no need to test on device until it’s ready.
Reusable code - Same monitoring strategy works for both pumps, just different config. Write once, configure many times.
Actually fits in 25KB - Without minification I’d be at 79% of budget already. With it, 33%. Plenty of room.
Downsides
No npm packages - Most are too big or use Node.js APIs. I just write small utilities when needed.
No async/await - ES5 doesn’t have it. Shelly APIs use callbacks anyway, so not a big deal.
Build step required - Can’t edit on device directly anymore. Worth it though.
Source maps don’t work - Shelly can’t use them. I keep the dev build (non-minified) around for debugging if needed.
** We need to keep in mind we’re aiming to produce espruino compatible javascript, so not every js feature will work*
Setting this up yourself
Install the build tools:
npm install --save-dev rollup @rollup/plugin-node-resolve @rollup/plugin-replace rollup-plugin-swc3 typescript jshint
Create type definitions (src/types/shelly.d.ts):
declare const Shelly: {
call: (method: string, params: any, callback: Function) => void;
addStatusHandler: (handler: Function) => number;
emitEvent: (event: string, data: any) => void;
};
declare const Timer: {
set: (interval: number, repeat: boolean, callback: Function) => number;
};
Configure TypeScript (tsconfig.json):
{
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"noEmit": true,
"target": "ES5",
"module": "ES2015"
},
"include": ["src/**/*"]
}
Write a script (src/main.js):
/**
* @param {string} message
*/
function logMessage(message) {
print("LOG: " + message);
}
logMessage("Hello Shelly!");
Timer.set(60000, true, function() {
logMessage("Tick");
});
Build and deploy:
npm run build
# Then copy/paste dist/main.min.js to Shelly web UI
My deployment workflow
I use SourceHut’s build pipeline - it runs the build on every push (incredibly fast, you should try it) and I can download the artifact from there. Then I just paste it into the Shelly web UI.
For quick iteration I sometimes do:
npm run build && cat dist/ost/basement-pumps/basement-pumps-v0.0.56.js | wl-copy
Then paste in the web UI. Not elegant but works.
Real example
The basement pump monitoring script I mentioned:
- Monitors 2 sump pumps (power consumption, runtime, frequency)
- Sends PagerDuty alerts when something’s wrong
- Exports metrics to Grafana every minute
- 3 detection layers (power signature in 90s, duration in 2hr, frequency over 24hr)
Final size: 8,141 bytes (33% of 25KB budget) Timers used: 1 of 5 Build time: ~100ms
Source has comments, types, multiple files with imports. Build output is one minified ES5 file. Under a second to build.
Wrapping up
The build pipeline setup takes maybe an hour. After that, you can write maintainable code with type safety and actually iterate quickly. The minification means you can write readable code and still fit in 25KB.
For simple scripts (blink an LED, turn on a relay at sunset), this is overkill. But once you’re doing anything non-trivial - monitoring, complex logic, reusable components - the tooling pays off fast.
The next post covers how I used this setup to build a stuck pump detector that went from 2-hour detection to 90-second alerts by analyzing power consumption patterns in Grafana.