lakutata 3.0.0 → 3.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.
- package/README.md +50 -30
- package/README.zh-CN.md +130 -0
- package/dist/cjs/exports/QueueComponent.d.ts +1 -0
- package/dist/cjs/exports/QueueComponent.js +20 -0
- package/dist/esm/exports/QueueComponent.js +1 -0
- package/dist/types/exports/QueueComponent.d.ts +1 -0
- package/doc/en/Application.md +107 -0
- package/doc/en/CLI.md +274 -0
- package/doc/en/Cache.md +379 -0
- package/doc/en/Command.md +110 -0
- package/doc/en/ContainerdIntegration.md +17 -16
- package/doc/en/Controllers.md +256 -0
- package/doc/en/DBus.md +291 -0
- package/doc/en/DTOValidation.md +72 -43
- package/doc/en/Database.md +188 -0
- package/doc/en/DatabaseDrivers.md +287 -0
- package/doc/en/Decorator.md +52 -40
- package/doc/en/DockerIntegration.md +7 -6
- package/doc/en/Entities.md +240 -0
- package/doc/en/Entrypoint.md +216 -0
- package/doc/en/Exceptions.md +174 -0
- package/doc/en/GRPC.md +251 -0
- package/doc/en/GettingStarted.md +165 -0
- package/doc/en/HTTP.md +507 -0
- package/doc/en/HelperFunctions.md +11 -11
- package/doc/en/I18n.md +119 -115
- package/doc/en/Injection.md +144 -0
- package/doc/en/Installers.md +280 -0
- package/doc/en/Logger.md +206 -0
- package/doc/en/MCP.md +293 -0
- package/doc/en/Middlewares.md +213 -0
- package/doc/en/Migrations.md +202 -0
- package/doc/en/MongoEmbedded.md +149 -0
- package/doc/en/Monitor.md +164 -0
- package/doc/en/Mutex.md +27 -25
- package/doc/en/NATS.md +246 -0
- package/doc/en/NativeAddons.md +212 -0
- package/doc/en/Objects.md +143 -0
- package/doc/en/Overview.md +62 -85
- package/doc/en/Packaging.md +193 -0
- package/doc/en/ProjectStructure.md +124 -0
- package/doc/en/Queue.md +269 -0
- package/doc/en/Schedules.md +217 -0
- package/doc/en/Security.md +10 -9
- package/doc/en/ServiceCalls.md +504 -0
- package/doc/en/Time.md +19 -20
- package/doc/en/UpgradingFrom1.md +435 -0
- package/doc/en/UpgradingFrom2.md +315 -0
- package/doc/en/Workspaces.md +189 -0
- package/doc/en/examples/BackgroundJobs.md +677 -0
- package/doc/en/examples/CommandLineTool.md +664 -0
- package/doc/en/examples/DesktopApp.md +809 -0
- package/doc/en/examples/DeviceService.md +607 -0
- package/doc/en/examples/MCPServer.md +653 -0
- package/doc/en/examples/README.md +16 -0
- package/doc/en/examples/RestAPI.md +490 -0
- package/doc/en/examples/ServiceCalls.md +829 -0
- package/doc/en/examples/WebApp.md +568 -0
- package/doc/zh/Application.md +75 -0
- package/doc/zh/CLI.md +250 -0
- package/doc/zh/Cache.md +328 -0
- package/doc/zh/Command.md +78 -0
- package/doc/zh/ContainerdIntegration.md +9 -9
- package/doc/zh/Controllers.md +228 -0
- package/doc/zh/DBus.md +257 -0
- package/doc/zh/DTOValidation.md +49 -19
- package/doc/zh/Database.md +175 -0
- package/doc/zh/DatabaseDrivers.md +261 -0
- package/doc/zh/Decorator.md +25 -18
- package/doc/zh/DockerIntegration.md +6 -6
- package/doc/zh/Entities.md +224 -0
- package/doc/zh/Entrypoint.md +193 -0
- package/doc/zh/Exceptions.md +158 -0
- package/doc/zh/GRPC.md +226 -0
- package/doc/zh/GettingStarted.md +148 -0
- package/doc/zh/HTTP.md +445 -0
- package/doc/zh/HelperFunctions.md +2 -2
- package/doc/zh/I18n.md +26 -26
- package/doc/zh/Injection.md +129 -0
- package/doc/zh/Installers.md +232 -0
- package/doc/zh/Logger.md +188 -0
- package/doc/zh/MCP.md +265 -0
- package/doc/zh/Middlewares.md +198 -0
- package/doc/zh/Migrations.md +178 -0
- package/doc/zh/MongoEmbedded.md +118 -0
- package/doc/zh/Monitor.md +145 -0
- package/doc/zh/Mutex.md +10 -10
- package/doc/zh/NATS.md +207 -0
- package/doc/zh/NativeAddons.md +180 -0
- package/doc/zh/Objects.md +119 -0
- package/doc/zh/Overview.md +46 -71
- package/doc/zh/Packaging.md +154 -0
- package/doc/zh/ProjectStructure.md +102 -0
- package/doc/zh/Queue.md +214 -0
- package/doc/zh/Schedules.md +193 -0
- package/doc/zh/Security.md +6 -6
- package/doc/zh/ServiceCalls.md +459 -0
- package/doc/zh/Time.md +2 -2
- package/doc/zh/UpgradingFrom1.md +347 -0
- package/doc/zh/UpgradingFrom2.md +230 -0
- package/doc/zh/Workspaces.md +150 -0
- package/doc/zh/examples/BackgroundJobs.md +618 -0
- package/doc/zh/examples/CommandLineTool.md +618 -0
- package/doc/zh/examples/DesktopApp.md +748 -0
- package/doc/zh/examples/DeviceService.md +546 -0
- package/doc/zh/examples/MCPServer.md +604 -0
- package/doc/zh/examples/README.md +14 -0
- package/doc/zh/examples/RestAPI.md +458 -0
- package/doc/zh/examples/ServiceCalls.md +765 -0
- package/doc/zh/examples/WebApp.md +520 -0
- package/package.json +46 -17
- package/doc/en/ActionPattern.md +0 -408
- package/doc/en/Core.md +0 -312
- package/doc/en/ORMIntegration.md +0 -341
- package/doc/zh/ActionPattern.md +0 -334
- package/doc/zh/Core.md +0 -256
- package/doc/zh/ORMIntegration.md +0 -319
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
# A service on a device
|
|
2
|
+
|
|
3
|
+
This example builds a service for a small Linux machine, such as a Raspberry Pi or a gateway: it reads the load, the
|
|
4
|
+
memory and the temperature of the machine every 10 seconds, keeps the last hour of readings in memory, and serves them
|
|
5
|
+
over HTTP. It starts from the `service` template with its `installer` feature, and shows a scheduled action, components
|
|
6
|
+
of its own with their options, the lakutata monitors, configuration through environment variables, and the packages that
|
|
7
|
+
install the service on the machine and start it with the system.
|
|
8
|
+
|
|
9
|
+
| Method | Route | What it does |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| `GET` | `/readings?limit=10` | Gives the latest readings, the newest first |
|
|
12
|
+
| `GET` | `/status` | Gives the uptime of the service, its number of readings, and its own CPU and memory use |
|
|
13
|
+
| `GET` | `/health` | Tells whether the process answers (from the template) |
|
|
14
|
+
|
|
15
|
+
## 1. Create the project
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npx @lakutata/cli create --name telemetry --template service --with installer --yes
|
|
19
|
+
cd telemetry
|
|
20
|
+
rm packages/app/src/controllers/ExampleController.ts packages/app/src/dtos/GreetDTO.ts
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The project is a workspace whose service is `packages/app` ([ProjectStructure](../ProjectStructure.md)). The `installer`
|
|
24
|
+
feature adds what is needed to make the installers: the `lakutata.installer` configuration in
|
|
25
|
+
`packages/app/package.json`, the `package:host` and `package:linux` scripts, `Dockerfile.installer`,
|
|
26
|
+
`@lakutata/builder`, and the icon in `packages/resources`. The service needs no other package: the schedules and the
|
|
27
|
+
monitors are part of lakutata. The example controller of the template is removed.
|
|
28
|
+
|
|
29
|
+
## 2. The sensors
|
|
30
|
+
|
|
31
|
+
**`packages/app/src/components/Sensors.ts`**
|
|
32
|
+
|
|
33
|
+
```typescript
|
|
34
|
+
import os from 'node:os'
|
|
35
|
+
import {readFile} from 'node:fs/promises'
|
|
36
|
+
import {Component, DTO} from 'lakutata'
|
|
37
|
+
import {Configurable} from 'lakutata/decorator/di'
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* What the sensors measure
|
|
41
|
+
*/
|
|
42
|
+
export type Measures = {
|
|
43
|
+
/**
|
|
44
|
+
* The load average of the machine over the last minute
|
|
45
|
+
*/
|
|
46
|
+
load: number
|
|
47
|
+
/**
|
|
48
|
+
* The memory in use, as a percentage of the memory of the machine
|
|
49
|
+
*/
|
|
50
|
+
memory: number
|
|
51
|
+
/**
|
|
52
|
+
* The temperature of the CPU in degrees Celsius, or null when the system does not report it
|
|
53
|
+
*/
|
|
54
|
+
temperature: number | null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The sensors of the machine: its load, its memory and the temperature of its CPU. On a device with its own sensors
|
|
59
|
+
* (on a serial port or I2C), this is the component that reads them
|
|
60
|
+
*/
|
|
61
|
+
export class Sensors extends Component {
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The temperature file, in thousandths of a degree (a Linux thermal zone)
|
|
65
|
+
*/
|
|
66
|
+
@Configurable(DTO.String().required())
|
|
67
|
+
protected readonly temperatureFile: string
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Read the sensors
|
|
71
|
+
*/
|
|
72
|
+
public async read(): Promise<Measures> {
|
|
73
|
+
return {
|
|
74
|
+
load: Math.round(os.loadavg()[0] * 100) / 100,
|
|
75
|
+
memory: Math.round((1 - os.freemem() / os.totalmem()) * 1000) / 10,
|
|
76
|
+
temperature: await this.temperature()
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The temperature, or null when its file cannot be read (macOS, Windows, a machine without a thermal zone)
|
|
82
|
+
*/
|
|
83
|
+
protected async temperature(): Promise<number | null> {
|
|
84
|
+
try {
|
|
85
|
+
return Math.round(Number(await readFile(this.temperatureFile, 'utf8')) / 100) / 10
|
|
86
|
+
} catch {
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
A component is a long-lived object, one for the whole application ([Objects](../Objects.md)). This one:
|
|
94
|
+
|
|
95
|
+
- reads the load average over the last minute (`os.loadavg()`, always 0 on Windows), and the share of the memory in use;
|
|
96
|
+
- reads the temperature of the CPU from a Linux thermal zone: a file that holds thousandths of a degree (`48312` for
|
|
97
|
+
48.3 °C). On a machine without one, such as a Mac, the temperature is `null`;
|
|
98
|
+
- has an option, `temperatureFile`: when the component is created, `@Configurable` checks the value given in the options
|
|
99
|
+
of the service against its schema.
|
|
100
|
+
|
|
101
|
+
On a device with its own sensors (on a serial port or I2C), this is the component that reads them; the rest of the
|
|
102
|
+
service stays the same.
|
|
103
|
+
|
|
104
|
+
## 3. The readings
|
|
105
|
+
|
|
106
|
+
**`packages/app/src/components/Readings.ts`**
|
|
107
|
+
|
|
108
|
+
```typescript
|
|
109
|
+
import {Component, DTO} from 'lakutata'
|
|
110
|
+
import {Configurable} from 'lakutata/decorator/di'
|
|
111
|
+
import {type Measures} from './Sensors.js'
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* A reading: the measures of the sensors, and when they were taken
|
|
115
|
+
*/
|
|
116
|
+
export type Reading = { time: string } & Measures
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The recent readings, kept in memory, at most size of them: a new one replaces the oldest
|
|
120
|
+
*/
|
|
121
|
+
export class Readings extends Component {
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* How many readings are kept
|
|
125
|
+
*/
|
|
126
|
+
@Configurable(DTO.Number().integer().min(1).required())
|
|
127
|
+
protected readonly size: number
|
|
128
|
+
|
|
129
|
+
protected readonly readings: Reading[] = []
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The number of readings kept
|
|
133
|
+
*/
|
|
134
|
+
public get count(): number {
|
|
135
|
+
return this.readings.length
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Keep a reading
|
|
140
|
+
* @param reading
|
|
141
|
+
*/
|
|
142
|
+
public add(reading: Reading): void {
|
|
143
|
+
this.readings.push(reading)
|
|
144
|
+
if (this.readings.length > this.size) this.readings.shift()
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The latest readings, the newest first
|
|
149
|
+
* @param limit
|
|
150
|
+
*/
|
|
151
|
+
public latest(limit: number): Reading[] {
|
|
152
|
+
return this.readings.slice(-limit).reverse()
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
- The readings are kept in memory, at most `size` of them (an option, like `temperatureFile`): a new reading replaces
|
|
158
|
+
the oldest one. They are lost when the service restarts; section 11 shows how to keep them.
|
|
159
|
+
- A component is a singleton: the scheduled action that adds the readings and the HTTP actions that read them share the
|
|
160
|
+
same instance, although each run and each request gets its own controller.
|
|
161
|
+
|
|
162
|
+
## 4. The input
|
|
163
|
+
|
|
164
|
+
**`packages/app/src/dtos/ReadingsDTO.ts`**
|
|
165
|
+
|
|
166
|
+
```typescript
|
|
167
|
+
import {DTO} from 'lakutata'
|
|
168
|
+
import {Expect} from 'lakutata/decorator/dto'
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* How many of the latest readings to return (the query string is text, which strict(false) converts)
|
|
172
|
+
*/
|
|
173
|
+
export class ReadingsDTO extends DTO {
|
|
174
|
+
@Expect(DTO.Number().integer().min(1).max(1000).strict(false).default(10))
|
|
175
|
+
public limit: number
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
`limit` is the number of readings to return: from 1 to 1000, 10 by default. The query string is text: `.strict(false)`
|
|
180
|
+
converts `'3'` to the number 3, and an invalid value is refused before the action runs
|
|
181
|
+
([DTOValidation](../DTOValidation.md)).
|
|
182
|
+
|
|
183
|
+
## 5. The controller
|
|
184
|
+
|
|
185
|
+
**`packages/app/src/controllers/ReadingController.ts`**
|
|
186
|
+
|
|
187
|
+
```typescript
|
|
188
|
+
import {Controller, type ScheduleContext} from 'lakutata/com/entrypoint'
|
|
189
|
+
import {type CpuMonitor, type MemoryMonitor} from 'lakutata/com/monitor'
|
|
190
|
+
import {GET, Schedule} from 'lakutata/decorator/ctrl'
|
|
191
|
+
import {Inject} from 'lakutata/decorator/di'
|
|
192
|
+
import {type Reading, type Readings} from '../components/Readings.js'
|
|
193
|
+
import {type Sensors} from '../components/Sensors.js'
|
|
194
|
+
import {ReadingsDTO} from '../dtos/ReadingsDTO.js'
|
|
195
|
+
|
|
196
|
+
export class ReadingController extends Controller {
|
|
197
|
+
|
|
198
|
+
@Inject('sensors')
|
|
199
|
+
protected readonly sensors: Sensors
|
|
200
|
+
|
|
201
|
+
@Inject('readings')
|
|
202
|
+
protected readonly readings: Readings
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The CPU and the memory of the service itself
|
|
206
|
+
*/
|
|
207
|
+
@Inject('cpu')
|
|
208
|
+
protected readonly cpu: CpuMonitor
|
|
209
|
+
|
|
210
|
+
@Inject('memory')
|
|
211
|
+
protected readonly memory: MemoryMonitor
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Reads the sensors at the start, then every SAMPLE_EVERY (10 seconds by default)
|
|
215
|
+
*/
|
|
216
|
+
@Schedule({every: process.env.SAMPLE_EVERY ?? '10s', immediate: true})
|
|
217
|
+
public async sample(): Promise<void> {
|
|
218
|
+
const context: ScheduleContext = this.context as ScheduleContext
|
|
219
|
+
this.readings.add({time: context.scheduledAt.toISOString(), ...await this.sensors.read()})
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* GET /readings?limit=10: the latest readings, the newest first
|
|
224
|
+
* @param input
|
|
225
|
+
*/
|
|
226
|
+
@GET('/readings', ReadingsDTO)
|
|
227
|
+
public async list(input: ReadingsDTO): Promise<Reading[]> {
|
|
228
|
+
return this.readings.latest(input.limit)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* GET /status: the service itself, as measured by the monitors since it started
|
|
233
|
+
*/
|
|
234
|
+
@GET('/status')
|
|
235
|
+
public async status(): Promise<{ uptime: number, readings: number, cpu: number, memory: number }> {
|
|
236
|
+
return {
|
|
237
|
+
uptime: Math.round(process.uptime()),
|
|
238
|
+
readings: this.readings.count,
|
|
239
|
+
//The mean CPU usage, in percent of one core
|
|
240
|
+
cpu: this.cpu.statistics.usageAvg,
|
|
241
|
+
//The resident memory, in bytes
|
|
242
|
+
memory: this.memory.statistics.physicalUsed
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
- A controller can hold the actions of several transports ([Controllers](../Controllers.md)): `sample()` is a
|
|
249
|
+
scheduled action, `list()` and `status()` are HTTP actions. Each run of `sample()` is handled like a request, with a
|
|
250
|
+
new controller.
|
|
251
|
+
- `@Schedule({every, immediate: true})` runs `sample()` once at the start, then once every interval
|
|
252
|
+
([Schedules](../Schedules.md)). `this.context` is the `ScheduleContext` of the run: its `scheduledAt`, the time the
|
|
253
|
+
run was planned for, becomes the time of the reading.
|
|
254
|
+
- The interval is read from `SAMPLE_EVERY` when the class is defined, that is, when its module is loaded, so it comes
|
|
255
|
+
from the environment the process starts with (the shell, or the environment file of the installed service). A value
|
|
256
|
+
that is not a duration, such as `30` without a unit, stops the service at startup:
|
|
257
|
+
`SyntaxError: Invalid duration "30": such as "500ms", "30s", "5m", "1h30m" or "1d"`.
|
|
258
|
+
- The monitors measure the service itself, not the machine ([Monitor](../Monitor.md)): `usageAvg` is the mean CPU usage
|
|
259
|
+
of the process since its start, in percent of one core, and `physicalUsed` is its resident memory, in bytes. They show
|
|
260
|
+
whether the service stays light on a small machine.
|
|
261
|
+
|
|
262
|
+
## 6. The options of the service
|
|
263
|
+
|
|
264
|
+
**`packages/app/src/config/Config.ts`**
|
|
265
|
+
|
|
266
|
+
```typescript
|
|
267
|
+
import type {ApplicationOptions} from 'lakutata'
|
|
268
|
+
import {buildEntrypoints, scheduleEntrypoint} from 'lakutata/com/entrypoint'
|
|
269
|
+
import {expressHTTPEntrypoint, jsonEnvelope} from 'lakutata/adapter/express'
|
|
270
|
+
import {CpuMonitor, MemoryMonitor} from 'lakutata/com/monitor'
|
|
271
|
+
import compression from 'compression'
|
|
272
|
+
import {authenticate} from '../middlewares/Authenticate.js'
|
|
273
|
+
import {Readings} from '../components/Readings.js'
|
|
274
|
+
import {Sensors} from '../components/Sensors.js'
|
|
275
|
+
import {HealthController} from '../controllers/HealthController.js'
|
|
276
|
+
import {ReadingController} from '../controllers/ReadingController.js'
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* The options of the service
|
|
280
|
+
* @param port the port of the HTTP server, the PORT environment variable or 8080 by default
|
|
281
|
+
* @param kept the number of readings kept, the READINGS_KEPT environment variable or 360 by default (an hour, with a
|
|
282
|
+
* reading every 10 seconds)
|
|
283
|
+
*/
|
|
284
|
+
export function config(port: number = Number(process.env.PORT ?? 8080), kept: number = Number(process.env.READINGS_KEPT ?? 360)): ApplicationOptions {
|
|
285
|
+
return {
|
|
286
|
+
id: 'telemetry',
|
|
287
|
+
name: 'telemetry',
|
|
288
|
+
mode: process.env.NODE_ENV === 'production' ? 'production' : 'development',
|
|
289
|
+
components: {
|
|
290
|
+
//The monitors of the service's own CPU and memory
|
|
291
|
+
cpu: {class: CpuMonitor},
|
|
292
|
+
memory: {class: MemoryMonitor},
|
|
293
|
+
sensors: {class: Sensors, temperatureFile: process.env.TEMPERATURE_FILE ?? '/sys/class/thermal/thermal_zone0/temp'},
|
|
294
|
+
readings: {class: Readings, size: kept},
|
|
295
|
+
entrypoint: buildEntrypoints({
|
|
296
|
+
controllers: [HealthController, ReadingController],
|
|
297
|
+
//Runs the @Schedule actions
|
|
298
|
+
schedule: scheduleEntrypoint(),
|
|
299
|
+
http: expressHTTPEntrypoint({
|
|
300
|
+
port: port,
|
|
301
|
+
//The results and the errors are {errno, errMsg, data}, with the status 200
|
|
302
|
+
respond: jsonEnvelope({errorStatus: 200}),
|
|
303
|
+
body: {limit: '1mb'},
|
|
304
|
+
server: {keepAliveTimeout: 60000, headersTimeout: 65000},
|
|
305
|
+
middlewares: [authenticate],
|
|
306
|
+
before: (app): void => void app.use(compression())
|
|
307
|
+
})
|
|
308
|
+
})
|
|
309
|
+
},
|
|
310
|
+
//The monitors measure from the start
|
|
311
|
+
bootstrap: ['cpu', 'memory', 'entrypoint']
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
This is the `Config.ts` of the template, with:
|
|
317
|
+
|
|
318
|
+
- the monitors `cpu` and `memory`, listed in `bootstrap` so that they measure from the start;
|
|
319
|
+
- the `sensors` and `readings` components, with their options;
|
|
320
|
+
- `ReadingController` instead of the example controller, and the schedule adapter, `scheduleEntrypoint()`, without
|
|
321
|
+
which the `@Schedule` actions never run ([Entrypoint](../Entrypoint.md));
|
|
322
|
+
- a limit of 1 MB on the request bodies instead of 100 MB: the API only reads query strings.
|
|
323
|
+
|
|
324
|
+
The service is configured through environment variables:
|
|
325
|
+
|
|
326
|
+
| Variable | Default | What it sets |
|
|
327
|
+
|---|---|---|
|
|
328
|
+
| `PORT` | `8080` | The port of the HTTP API |
|
|
329
|
+
| `SAMPLE_EVERY` | `10s` | The interval between the readings: a duration such as `500ms`, `30s` or `5m` |
|
|
330
|
+
| `READINGS_KEPT` | `360` | How many readings are kept |
|
|
331
|
+
| `TEMPERATURE_FILE` | `/sys/class/thermal/thermal_zone0/temp` | The temperature file |
|
|
332
|
+
| `API_TOKEN` | None | When it is set, the requests must carry `Authorization: Bearer <API_TOKEN>` (the middleware of the template) |
|
|
333
|
+
| `MODE` | `development` | `production` for the installed service; it wins over the `mode` option ([Application](../Application.md)) |
|
|
334
|
+
|
|
335
|
+
An invalid value stops the service at startup, and the log says why:
|
|
336
|
+
|
|
337
|
+
```bash
|
|
338
|
+
READINGS_KEPT=0 npm start
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
```
|
|
342
|
+
[2026-10-17T14:55:03.108Z] ERROR (telemetry): The service cannot start: The option "size" of "readings" (Readings) is invalid: "size" must be greater than or equal to 1
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
## 7. The installers
|
|
346
|
+
|
|
347
|
+
**`packages/app/package.json`**
|
|
348
|
+
|
|
349
|
+
```json
|
|
350
|
+
{
|
|
351
|
+
"name": "app",
|
|
352
|
+
"version": "0.0.1",
|
|
353
|
+
"description": "Reads the load, the memory and the temperature of the machine, and serves them over HTTP",
|
|
354
|
+
"author": "",
|
|
355
|
+
"license": "UNLICENSED",
|
|
356
|
+
"private": true,
|
|
357
|
+
"type": "module",
|
|
358
|
+
"main": "build/App.js",
|
|
359
|
+
"scripts": {
|
|
360
|
+
"clean": "lakutata clean",
|
|
361
|
+
"build": "tsc",
|
|
362
|
+
"rebuild": "npm run clean && npm run build",
|
|
363
|
+
"dev": "npm run rebuild && node build/App.js",
|
|
364
|
+
"start": "node build/App.js",
|
|
365
|
+
"test": "node --test \"build/tests/**/*.spec.js\"",
|
|
366
|
+
"package": "lakutata package installer"
|
|
367
|
+
},
|
|
368
|
+
"dependencies": {
|
|
369
|
+
"compression": "^1.8.1",
|
|
370
|
+
"express": "^5.2.1",
|
|
371
|
+
"lakutata": "^3.0.0"
|
|
372
|
+
},
|
|
373
|
+
"devDependencies": {
|
|
374
|
+
"@types/compression": "^1.8.1",
|
|
375
|
+
"@types/express": "^5.0.6"
|
|
376
|
+
},
|
|
377
|
+
"bin": {
|
|
378
|
+
"telemetry": "build/App.js"
|
|
379
|
+
},
|
|
380
|
+
"lakutata": {
|
|
381
|
+
"icon": "../resources/logo.png",
|
|
382
|
+
"installer": {
|
|
383
|
+
"name": "telemetry",
|
|
384
|
+
"service": {
|
|
385
|
+
"env": {
|
|
386
|
+
"PORT": "8080",
|
|
387
|
+
"SAMPLE_EVERY": "10s",
|
|
388
|
+
"READINGS_KEPT": "360"
|
|
389
|
+
},
|
|
390
|
+
"restart": "always"
|
|
391
|
+
},
|
|
392
|
+
"linux": {
|
|
393
|
+
"targets": [
|
|
394
|
+
"deb",
|
|
395
|
+
"rpm",
|
|
396
|
+
"tar.gz"
|
|
397
|
+
]
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
This is the `package.json` made by the `installer` feature, with a description and the options of the service added
|
|
405
|
+
([Installers](../Installers.md)):
|
|
406
|
+
|
|
407
|
+
- `bin` names the program, `telemetry`, which the packages put in the PATH (`/usr/bin/telemetry` on Linux);
|
|
408
|
+
- `description` describes the packages and the service;
|
|
409
|
+
- `service` registers the service with the system: systemd on Linux, launchd on macOS, a Windows service on Windows. The
|
|
410
|
+
installation enables and starts it, an upgrade restarts it, and the removal stops and disables it;
|
|
411
|
+
- `service.env` is the environment of the service, written to its environment file (section 10);
|
|
412
|
+
- `restart: "always"` restarts the service whenever it ends; the default, `on-failure`, restarts it only when it fails;
|
|
413
|
+
- the service runs as `telemetry`, a system user that the installation creates, in its working directory,
|
|
414
|
+
`/var/lib/telemetry`, where it can write its data;
|
|
415
|
+
- `linux.targets` lists the Linux packages: deb (Debian, Ubuntu, Raspberry Pi OS), rpm (Fedora, Red Hat) and a tar.gz
|
|
416
|
+
archive, which holds only the application, without the service.
|
|
417
|
+
|
|
418
|
+
The packages take their maintainer from `lakutata.installer.maintainer`, or else from the `author` of the
|
|
419
|
+
`package.json`; without one, the tool warns.
|
|
420
|
+
|
|
421
|
+
## 8. Run it
|
|
422
|
+
|
|
423
|
+
```bash
|
|
424
|
+
SAMPLE_EVERY=2s npm run dev
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
In another terminal:
|
|
428
|
+
|
|
429
|
+
```bash
|
|
430
|
+
curl 'localhost:8080/readings?limit=3'
|
|
431
|
+
# {"errno":null,"errMsg":null,"data":[{"time":"2026-10-17T14:50:33.497Z","load":1.94,"memory":75.8,"temperature":null},{"time":"2026-10-17T14:50:31.497Z","load":1.94,"memory":75.8,"temperature":null},{"time":"2026-10-17T14:50:29.497Z","load":1.94,"memory":75.9,"temperature":null}]}
|
|
432
|
+
|
|
433
|
+
curl localhost:8080/status
|
|
434
|
+
# {"errno":null,"errMsg":null,"data":{"uptime":6,"readings":3,"cpu":0.29407,"memory":117686272}}
|
|
435
|
+
|
|
436
|
+
curl 'localhost:8080/readings?limit=abc'
|
|
437
|
+
# {"errno":"E_INVALID_VALUE","errMsg":"\"limit\" must be a number","data":null}
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
The first reading is taken at startup, then one every 2 seconds. The temperature is `null` here because the service ran
|
|
441
|
+
on a Mac; on a Linux machine with a thermal zone, it is the temperature of its CPU in degrees Celsius.
|
|
442
|
+
|
|
443
|
+
## 9. Test it
|
|
444
|
+
|
|
445
|
+
The test of the template is replaced by a test of the readings. It writes its own thermal zone, a file that reads
|
|
446
|
+
42.5 degrees, and starts the service with a reading every 200 milliseconds, keeping 3 readings:
|
|
447
|
+
|
|
448
|
+
**`packages/app/src/tests/App.spec.ts`**
|
|
449
|
+
|
|
450
|
+
```typescript
|
|
451
|
+
import {after, before, describe, it} from 'node:test'
|
|
452
|
+
import assert from 'node:assert'
|
|
453
|
+
import {mkdtemp, rm, writeFile} from 'node:fs/promises'
|
|
454
|
+
import os from 'node:os'
|
|
455
|
+
import path from 'node:path'
|
|
456
|
+
import {setTimeout as sleep} from 'node:timers/promises'
|
|
457
|
+
import {Application} from 'lakutata'
|
|
458
|
+
|
|
459
|
+
const PORT: number = Number(process.env.TEST_PORT ?? 18089)
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* A request to the service, with the token: its {errno, errMsg, data}
|
|
463
|
+
* @param route
|
|
464
|
+
*/
|
|
465
|
+
async function request(route: string): Promise<{ errno: string | null, data: any }> {
|
|
466
|
+
const response: Response = await fetch(`http://127.0.0.1:${PORT}${route}`, {headers: {Authorization: 'Bearer test-token'}})
|
|
467
|
+
return await response.json() as { errno: string | null, data: any }
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
describe('telemetry', (): void => {
|
|
471
|
+
|
|
472
|
+
let app: Application
|
|
473
|
+
let directory: string
|
|
474
|
+
|
|
475
|
+
before(async (): Promise<void> => {
|
|
476
|
+
//A thermal zone of 42.5 degrees
|
|
477
|
+
directory = await mkdtemp(path.join(os.tmpdir(), 'telemetry-'))
|
|
478
|
+
await writeFile(path.join(directory, 'temp'), '42500\n')
|
|
479
|
+
process.env.TEMPERATURE_FILE = path.join(directory, 'temp')
|
|
480
|
+
process.env.API_TOKEN = 'test-token'
|
|
481
|
+
//A reading every 200 milliseconds. The interval is read when the controller is loaded, so the options are
|
|
482
|
+
//imported only after SAMPLE_EVERY is set
|
|
483
|
+
process.env.SAMPLE_EVERY = '200ms'
|
|
484
|
+
const {config} = await import('../config/Config.js')
|
|
485
|
+
app = await new Promise<Application>((resolve: (app: Application) => void): void => {
|
|
486
|
+
Application.run(config(PORT, 3)).onLaunched((launched: Application): void => resolve(launched))
|
|
487
|
+
})
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
after(async (): Promise<void> => {
|
|
491
|
+
await app?.dispose()
|
|
492
|
+
await rm(directory, {recursive: true, force: true})
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
it('refuses the requests without authentication', async (): Promise<void> => {
|
|
496
|
+
const response: Response = await fetch(`http://127.0.0.1:${PORT}/readings`)
|
|
497
|
+
assert.deepStrictEqual(await response.json(), {errno: 'E_UNAUTHORIZED', errMsg: 'The request is not authenticated', data: null})
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
it('samples the sensors on its schedule and keeps the latest readings', async (): Promise<void> => {
|
|
501
|
+
//A reading at the start, then one every 200 milliseconds: the latest 3 are kept
|
|
502
|
+
await sleep(1000)
|
|
503
|
+
const {data: readings} = await request('/readings')
|
|
504
|
+
assert.strictEqual(readings.length, 3)
|
|
505
|
+
assert.ok(readings[0].time > readings[1].time && readings[1].time > readings[2].time)
|
|
506
|
+
for (const reading of readings) {
|
|
507
|
+
assert.strictEqual(reading.temperature, 42.5)
|
|
508
|
+
assert.ok(reading.load >= 0)
|
|
509
|
+
assert.ok(reading.memory > 0 && reading.memory <= 100)
|
|
510
|
+
}
|
|
511
|
+
assert.strictEqual((await request('/readings?limit=1')).data.length, 1)
|
|
512
|
+
})
|
|
513
|
+
|
|
514
|
+
it('refuses an invalid limit', async (): Promise<void> => {
|
|
515
|
+
assert.strictEqual((await request('/readings?limit=0')).errno, 'E_INVALID_VALUE')
|
|
516
|
+
})
|
|
517
|
+
|
|
518
|
+
it('gives the status of the service', async (): Promise<void> => {
|
|
519
|
+
const {data: status} = await request('/status')
|
|
520
|
+
assert.strictEqual(status.readings, 3)
|
|
521
|
+
assert.ok(status.cpu >= 0)
|
|
522
|
+
assert.ok(status.memory > 0)
|
|
523
|
+
})
|
|
524
|
+
})
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
The options are imported only after `SAMPLE_EVERY` is set, because the interval is read when the module of the
|
|
528
|
+
controller is loaded. `node --test` runs each test file in its own process, so the variables that a file sets do not
|
|
529
|
+
reach the others.
|
|
530
|
+
|
|
531
|
+
```bash
|
|
532
|
+
npm test
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
```
|
|
536
|
+
app │ ✔ refuses the requests without authentication
|
|
537
|
+
app │ ✔ samples the sensors on its schedule and keeps the latest readings
|
|
538
|
+
app │ ✔ refuses an invalid limit
|
|
539
|
+
app │ ✔ gives the status of the service
|
|
540
|
+
app │ ℹ pass 4
|
|
541
|
+
✔ test ran in app
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
## 10. Install it on the device
|
|
545
|
+
|
|
546
|
+
The installers hold an executable that embeds Node.js, built for the system and the architecture of the machine that
|
|
547
|
+
makes them ([Packaging](../Packaging.md)). The two scripts of the `installer` feature write the installers to
|
|
548
|
+
`dist/installer`:
|
|
549
|
+
|
|
550
|
+
- `npm run package:linux` makes the Linux packages in a Docker container (`Dockerfile.installer`), on macOS and Windows
|
|
551
|
+
too, for the architecture of Docker: arm64 on a Mac with Apple silicon, which is also the architecture of a
|
|
552
|
+
Raspberry Pi 4 or 5 running a 64-bit system. The file names hold the name of the package, its version and its
|
|
553
|
+
architecture: `telemetry_0.0.1_arm64.deb`, `telemetry-0.0.1-1.aarch64.rpm` and `telemetry-0.0.1-linux-arm64.tar.gz`.
|
|
554
|
+
For x64 machines, give Docker their platform:
|
|
555
|
+
`docker buildx build --platform linux/amd64 --file Dockerfile.installer --output type=local,dest=dist/installer .`
|
|
556
|
+
- `npm run package:host` makes the installers for the machine it runs on: the same packages on Linux; on macOS, a `pkg`
|
|
557
|
+
package (a launchd service) and a tar.gz archive; on Windows, the NSIS and MSI installers (a Windows service) and a
|
|
558
|
+
zip archive.
|
|
559
|
+
|
|
560
|
+
On a device running Debian or Raspberry Pi OS:
|
|
561
|
+
|
|
562
|
+
```bash
|
|
563
|
+
sudo apt install ./telemetry_0.0.1_arm64.deb
|
|
564
|
+
systemctl status telemetry # enabled and started
|
|
565
|
+
journalctl -u telemetry -f # its log
|
|
566
|
+
curl 'localhost:8080/readings?limit=1'
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
The environment file of the service, `/etc/telemetry/telemetry.env`, holds `MODE=production` and the `service.env` of
|
|
570
|
+
the `package.json`:
|
|
571
|
+
|
|
572
|
+
```
|
|
573
|
+
# The environment of the service telemetry, read when it starts (systemctl restart telemetry)
|
|
574
|
+
MODE="production"
|
|
575
|
+
PORT="8080"
|
|
576
|
+
SAMPLE_EVERY="10s"
|
|
577
|
+
READINGS_KEPT="360"
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
To change the configuration of the service, edit the file, then restart the service:
|
|
581
|
+
|
|
582
|
+
```bash
|
|
583
|
+
sudoedit /etc/telemetry/telemetry.env # SAMPLE_EVERY="30s", API_TOKEN="…"
|
|
584
|
+
sudo systemctl restart telemetry
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
An upgrade (`sudo apt install` of the next version) keeps your edits to the environment file and restarts the service.
|
|
588
|
+
`sudo apt remove telemetry` stops the service and removes the application, but keeps the environment file and the
|
|
589
|
+
working directory.
|
|
590
|
+
|
|
591
|
+
## 11. Next steps
|
|
592
|
+
|
|
593
|
+
- To keep the readings across restarts, store them in a SQLite database in the working directory of the service, with a
|
|
594
|
+
relative path (once installed, `readings.sqlite` is `/var/lib/telemetry/readings.sqlite`), as in
|
|
595
|
+
[A REST API with a database](RestAPI.md). Its driver, better-sqlite3, is a native addon, which the installers ship
|
|
596
|
+
next to the executable ([Packaging](../Packaging.md), section 3).
|
|
597
|
+
- To send the readings to a server, publish them from `sample()` over [NATS](../NATS.md), or add them to a
|
|
598
|
+
[queue](../Queue.md).
|
|
599
|
+
- On a Linux device, the service can also work with the system: call system services over [D-Bus](../DBus.md), or run
|
|
600
|
+
containers with [containerd](../ContainerdIntegration.md) or [Docker](../DockerIntegration.md).
|
|
601
|
+
|
|
602
|
+
<!--
|
|
603
|
+
check:
|
|
604
|
+
create: telemetry service installer
|
|
605
|
+
remove: packages/app/src/controllers/ExampleController.ts packages/app/src/dtos/GreetDTO.ts
|
|
606
|
+
run: npm test
|
|
607
|
+
-->
|