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.
Files changed (117) hide show
  1. package/README.md +50 -30
  2. package/README.zh-CN.md +130 -0
  3. package/dist/cjs/exports/QueueComponent.d.ts +1 -0
  4. package/dist/cjs/exports/QueueComponent.js +20 -0
  5. package/dist/esm/exports/QueueComponent.js +1 -0
  6. package/dist/types/exports/QueueComponent.d.ts +1 -0
  7. package/doc/en/Application.md +107 -0
  8. package/doc/en/CLI.md +274 -0
  9. package/doc/en/Cache.md +379 -0
  10. package/doc/en/Command.md +110 -0
  11. package/doc/en/ContainerdIntegration.md +17 -16
  12. package/doc/en/Controllers.md +256 -0
  13. package/doc/en/DBus.md +291 -0
  14. package/doc/en/DTOValidation.md +72 -43
  15. package/doc/en/Database.md +188 -0
  16. package/doc/en/DatabaseDrivers.md +287 -0
  17. package/doc/en/Decorator.md +52 -40
  18. package/doc/en/DockerIntegration.md +7 -6
  19. package/doc/en/Entities.md +240 -0
  20. package/doc/en/Entrypoint.md +216 -0
  21. package/doc/en/Exceptions.md +174 -0
  22. package/doc/en/GRPC.md +251 -0
  23. package/doc/en/GettingStarted.md +165 -0
  24. package/doc/en/HTTP.md +507 -0
  25. package/doc/en/HelperFunctions.md +11 -11
  26. package/doc/en/I18n.md +119 -115
  27. package/doc/en/Injection.md +144 -0
  28. package/doc/en/Installers.md +280 -0
  29. package/doc/en/Logger.md +206 -0
  30. package/doc/en/MCP.md +293 -0
  31. package/doc/en/Middlewares.md +213 -0
  32. package/doc/en/Migrations.md +202 -0
  33. package/doc/en/MongoEmbedded.md +149 -0
  34. package/doc/en/Monitor.md +164 -0
  35. package/doc/en/Mutex.md +27 -25
  36. package/doc/en/NATS.md +246 -0
  37. package/doc/en/NativeAddons.md +212 -0
  38. package/doc/en/Objects.md +143 -0
  39. package/doc/en/Overview.md +62 -85
  40. package/doc/en/Packaging.md +193 -0
  41. package/doc/en/ProjectStructure.md +124 -0
  42. package/doc/en/Queue.md +269 -0
  43. package/doc/en/Schedules.md +217 -0
  44. package/doc/en/Security.md +10 -9
  45. package/doc/en/ServiceCalls.md +504 -0
  46. package/doc/en/Time.md +19 -20
  47. package/doc/en/UpgradingFrom1.md +435 -0
  48. package/doc/en/UpgradingFrom2.md +315 -0
  49. package/doc/en/Workspaces.md +189 -0
  50. package/doc/en/examples/BackgroundJobs.md +677 -0
  51. package/doc/en/examples/CommandLineTool.md +664 -0
  52. package/doc/en/examples/DesktopApp.md +809 -0
  53. package/doc/en/examples/DeviceService.md +607 -0
  54. package/doc/en/examples/MCPServer.md +653 -0
  55. package/doc/en/examples/README.md +16 -0
  56. package/doc/en/examples/RestAPI.md +490 -0
  57. package/doc/en/examples/ServiceCalls.md +829 -0
  58. package/doc/en/examples/WebApp.md +568 -0
  59. package/doc/zh/Application.md +75 -0
  60. package/doc/zh/CLI.md +250 -0
  61. package/doc/zh/Cache.md +328 -0
  62. package/doc/zh/Command.md +78 -0
  63. package/doc/zh/ContainerdIntegration.md +9 -9
  64. package/doc/zh/Controllers.md +228 -0
  65. package/doc/zh/DBus.md +257 -0
  66. package/doc/zh/DTOValidation.md +49 -19
  67. package/doc/zh/Database.md +175 -0
  68. package/doc/zh/DatabaseDrivers.md +261 -0
  69. package/doc/zh/Decorator.md +25 -18
  70. package/doc/zh/DockerIntegration.md +6 -6
  71. package/doc/zh/Entities.md +224 -0
  72. package/doc/zh/Entrypoint.md +193 -0
  73. package/doc/zh/Exceptions.md +158 -0
  74. package/doc/zh/GRPC.md +226 -0
  75. package/doc/zh/GettingStarted.md +148 -0
  76. package/doc/zh/HTTP.md +445 -0
  77. package/doc/zh/HelperFunctions.md +2 -2
  78. package/doc/zh/I18n.md +26 -26
  79. package/doc/zh/Injection.md +129 -0
  80. package/doc/zh/Installers.md +232 -0
  81. package/doc/zh/Logger.md +188 -0
  82. package/doc/zh/MCP.md +265 -0
  83. package/doc/zh/Middlewares.md +198 -0
  84. package/doc/zh/Migrations.md +178 -0
  85. package/doc/zh/MongoEmbedded.md +118 -0
  86. package/doc/zh/Monitor.md +145 -0
  87. package/doc/zh/Mutex.md +10 -10
  88. package/doc/zh/NATS.md +207 -0
  89. package/doc/zh/NativeAddons.md +180 -0
  90. package/doc/zh/Objects.md +119 -0
  91. package/doc/zh/Overview.md +46 -71
  92. package/doc/zh/Packaging.md +154 -0
  93. package/doc/zh/ProjectStructure.md +102 -0
  94. package/doc/zh/Queue.md +214 -0
  95. package/doc/zh/Schedules.md +193 -0
  96. package/doc/zh/Security.md +6 -6
  97. package/doc/zh/ServiceCalls.md +459 -0
  98. package/doc/zh/Time.md +2 -2
  99. package/doc/zh/UpgradingFrom1.md +347 -0
  100. package/doc/zh/UpgradingFrom2.md +230 -0
  101. package/doc/zh/Workspaces.md +150 -0
  102. package/doc/zh/examples/BackgroundJobs.md +618 -0
  103. package/doc/zh/examples/CommandLineTool.md +618 -0
  104. package/doc/zh/examples/DesktopApp.md +748 -0
  105. package/doc/zh/examples/DeviceService.md +546 -0
  106. package/doc/zh/examples/MCPServer.md +604 -0
  107. package/doc/zh/examples/README.md +14 -0
  108. package/doc/zh/examples/RestAPI.md +458 -0
  109. package/doc/zh/examples/ServiceCalls.md +765 -0
  110. package/doc/zh/examples/WebApp.md +520 -0
  111. package/package.json +46 -17
  112. package/doc/en/ActionPattern.md +0 -408
  113. package/doc/en/Core.md +0 -312
  114. package/doc/en/ORMIntegration.md +0 -341
  115. package/doc/zh/ActionPattern.md +0 -334
  116. package/doc/zh/Core.md +0 -256
  117. 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
+ -->