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,809 @@
1
+ # A desktop application
2
+
3
+ This example builds a desktop notes application: the notes are Markdown files in a folder that the user chooses, and
4
+ the window lists, opens, saves and deletes them. It starts from the `electron-react` template and shows both sides of
5
+ an Electron application made with lakutata: the main process, whose actions read and write the files, open a system
6
+ dialog and push an event when the folder changes; and the React renderer, which calls these actions over IPC. It also
7
+ shows tests of the main process that run without Electron.
8
+
9
+ | Message | What it does |
10
+ |---|---|
11
+ | `{cmd: 'folder.get'}` | Gives the notes folder, `null` before one is chosen |
12
+ | `{cmd: 'folder.choose'}` | Asks the user for a folder in a system dialog, and remembers it |
13
+ | `{cmd: 'notes.list'}` | Lists the notes, the most recently modified first |
14
+ | `{cmd: 'notes.read', name: 'Shopping'}` | Gives the text of a note |
15
+ | `{cmd: 'notes.save', name: 'Shopping', text: '- bread'}` | Writes a note to the file `Shopping.md` |
16
+ | `{cmd: 'notes.delete', name: 'Shopping'}` | Deletes it |
17
+ | `{cmd: 'notes.reveal', name: 'Shopping'}` | Shows its file in the file manager (Finder, Explorer…) |
18
+
19
+ When files in the folder change, the main process pushes the `notes.changed` event to the windows.
20
+
21
+ ## 1. Create the project
22
+
23
+ ```bash
24
+ npx @lakutata/cli create --name notes --template electron-react --yes
25
+ cd notes
26
+ rm packages/app/src/controllers/ExampleController.ts packages/app/src/dtos/GreetDTO.ts
27
+ ```
28
+
29
+ The project is a workspace ([ProjectStructure](../ProjectStructure.md)): `packages/app` is the main process, a lakutata
30
+ application; `packages/ui` is the renderer, a React page built by Vite; `packages/resources` holds the icon. The
31
+ application needs no other package: it reads the files with `node:fs`, and its dialogs come from Electron. The example
32
+ controller of the template is removed.
33
+
34
+ ## 2. The input
35
+
36
+ **`packages/app/src/dtos/NoteDTO.ts`**
37
+
38
+ ```typescript
39
+ import {DTO} from 'lakutata'
40
+ import {Expect} from 'lakutata/decorator/dto'
41
+
42
+ /**
43
+ * A note by its name, which is its file name without .md: no slash, and no character that a file system refuses
44
+ */
45
+ export class NoteNameDTO extends DTO {
46
+ @Expect(DTO.String().trim().min(1).max(100).pattern(/^[^\\/:*?"<>|]+$/, 'file name').required())
47
+ public name: string
48
+ }
49
+
50
+ /**
51
+ * A note to write
52
+ */
53
+ export class SaveNoteDTO extends NoteNameDTO {
54
+ @Expect(DTO.String().allow('').max(1000000).required())
55
+ public text: string
56
+ }
57
+ ```
58
+
59
+ Each action validates its input with a DTO before it runs ([DTOValidation](../DTOValidation.md)):
60
+
61
+ - a note is a file `<name>.md` in the folder: the pattern refuses slashes and the characters that Windows does not
62
+ allow in file names, so a name cannot lead out of the folder. The second argument of `.pattern()` names the pattern
63
+ in the error message: `"name" with value "a/b" fails to match the file name pattern`;
64
+ - `.allow('')` accepts an empty note;
65
+ - a message is JSON, so its values keep their types: no `.strict(false)` is needed, unlike the query string of an HTTP
66
+ request ([ServiceCalls](../ServiceCalls.md), section 2).
67
+
68
+ ## 3. The exceptions
69
+
70
+ **`packages/app/src/exceptions/NoteExceptions.ts`**
71
+
72
+ ```typescript
73
+ import {Exception} from 'lakutata'
74
+
75
+ /**
76
+ * No notes folder has been chosen yet
77
+ */
78
+ export class NoFolderException extends Exception {
79
+ public errno: string = 'E_NO_FOLDER'
80
+ }
81
+
82
+ /**
83
+ * The requested note is not in the folder
84
+ */
85
+ export class NoteNotFoundException extends Exception {
86
+ public errno: string = 'E_NOTE_NOT_FOUND'
87
+ }
88
+ ```
89
+
90
+ An exception has an `errno`, the code that the renderer checks ([Exceptions](../Exceptions.md)). Over IPC, the error is
91
+ sent with all its properties ([ServiceCalls](../ServiceCalls.md), section 3), so the renderer receives it as it was
92
+ thrown: `error.errno` is `'E_NOTE_NOT_FOUND'`. The service transports always answer with an envelope, whatever the
93
+ status of the error, so these exceptions need no `statusCode`.
94
+
95
+ ## 4. The notes
96
+
97
+ **`packages/app/src/components/Notes.ts`**
98
+
99
+ ```typescript
100
+ import {Component, DTO} from 'lakutata'
101
+ import {type Entrypoint} from 'lakutata/com/entrypoint'
102
+ import {Configurable, Inject} from 'lakutata/decorator/di'
103
+ import {existsSync, watch, type FSWatcher} from 'node:fs'
104
+ import {mkdir, readdir, readFile, rm, stat, writeFile} from 'node:fs/promises'
105
+ import path from 'node:path'
106
+ import {NoFolderException, NoteNotFoundException} from '../exceptions/NoteExceptions.js'
107
+
108
+ /**
109
+ * A note of the folder: its name, and when its file was modified
110
+ */
111
+ export type NoteInfo = { name: string, modifiedAt: string }
112
+
113
+ /**
114
+ * The notes: the Markdown files in the folder that the user chose, which the settings of the application remember.
115
+ * Changes to its files, made by the application or by another program, are pushed to the windows (notes.changed)
116
+ */
117
+ export class Notes extends Component {
118
+
119
+ /**
120
+ * Pushes the events to the windows
121
+ */
122
+ @Inject('entrypoint')
123
+ protected readonly entrypoint: Entrypoint
124
+
125
+ /**
126
+ * The settings file, where the folder is remembered
127
+ */
128
+ @Configurable(DTO.String().required())
129
+ protected readonly settings: string
130
+
131
+ #folder: string | null = null
132
+
133
+ #watcher: FSWatcher | undefined
134
+
135
+ #timer: NodeJS.Timeout | undefined
136
+
137
+ /**
138
+ * The notes folder, null before one is chosen
139
+ */
140
+ public get folder(): string | null {
141
+ return this.#folder
142
+ }
143
+
144
+ /**
145
+ * Use the folder remembered in the settings, if it still exists
146
+ */
147
+ protected async init(): Promise<void> {
148
+ const settings: { folder?: string } = await readFile(this.settings, 'utf8').then((text: string) => JSON.parse(text), () => ({}))
149
+ if (settings.folder && existsSync(settings.folder)) this.watch(settings.folder)
150
+ }
151
+
152
+ protected async destroy(): Promise<void> {
153
+ clearTimeout(this.#timer)
154
+ this.#watcher?.close()
155
+ }
156
+
157
+ /**
158
+ * Watch the files of a folder: their changes are pushed to the windows once they settle
159
+ * @param folder
160
+ */
161
+ protected watch(folder: string): void {
162
+ this.#watcher?.close()
163
+ const watcher: FSWatcher = watch(folder, (): void => {
164
+ clearTimeout(this.#timer)
165
+ this.#timer = setTimeout((): number => this.entrypoint.broadcast('notes.changed'), 100)
166
+ })
167
+ //The folder was removed, or can no longer be read
168
+ watcher.on('error', (): void => watcher.close())
169
+ this.#watcher = watcher
170
+ this.#folder = folder
171
+ }
172
+
173
+ /**
174
+ * Use another folder, and remember it
175
+ * @param folder
176
+ */
177
+ public async use(folder: string): Promise<void> {
178
+ this.watch(folder)
179
+ await mkdir(path.dirname(this.settings), {recursive: true})
180
+ await writeFile(this.settings, JSON.stringify({folder: folder}))
181
+ }
182
+
183
+ /**
184
+ * The file of a note
185
+ * @param name
186
+ */
187
+ protected file(name: string): string {
188
+ if (!this.#folder) throw new NoFolderException('No folder of notes is chosen')
189
+ return path.join(this.#folder, `${name}.md`)
190
+ }
191
+
192
+ /**
193
+ * The file of an existing note
194
+ * @param name
195
+ */
196
+ public find(name: string): string {
197
+ const file: string = this.file(name)
198
+ if (!existsSync(file)) throw new NoteNotFoundException(`The note ${name} does not exist`)
199
+ return file
200
+ }
201
+
202
+ /**
203
+ * The notes, the most recently modified first
204
+ */
205
+ public async list(): Promise<NoteInfo[]> {
206
+ if (!this.#folder) return []
207
+ const notes: NoteInfo[] = []
208
+ for (const entry of await readdir(this.#folder, {withFileTypes: true})) {
209
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue
210
+ const modified: Date = (await stat(path.join(this.#folder, entry.name))).mtime
211
+ notes.push({name: entry.name.slice(0, -'.md'.length), modifiedAt: modified.toISOString()})
212
+ }
213
+ return notes.sort((a: NoteInfo, b: NoteInfo): number => b.modifiedAt.localeCompare(a.modifiedAt))
214
+ }
215
+
216
+ /**
217
+ * The text of a note
218
+ * @param name
219
+ */
220
+ public async read(name: string): Promise<string> {
221
+ return await readFile(this.find(name), 'utf8')
222
+ }
223
+
224
+ /**
225
+ * Write a note, new or existing
226
+ * @param name
227
+ * @param text
228
+ */
229
+ public async write(name: string, text: string): Promise<NoteInfo> {
230
+ const file: string = this.file(name)
231
+ await writeFile(file, text)
232
+ return {name: name, modifiedAt: (await stat(file)).mtime.toISOString()}
233
+ }
234
+
235
+ /**
236
+ * Delete a note
237
+ * @param name
238
+ */
239
+ public async remove(name: string): Promise<void> {
240
+ await rm(this.find(name))
241
+ }
242
+ }
243
+ ```
244
+
245
+ - A component is created once and shared by the whole application ([Objects](../Objects.md)). It keeps the folder,
246
+ which outlives the requests: a controller lives for one request only ([Controllers](../Controllers.md), section 2).
247
+ - `settings`, the file where the folder is remembered, is an option of the component: `@Configurable` declares it, and
248
+ the options of the application give its value ([Injection](../Injection.md)). `init()` reads the file when the
249
+ application starts.
250
+ - `watch()` from `node:fs` watches the folder. When its files change, whether the application or another program (an
251
+ editor, a sync tool) saved them, `this.entrypoint.broadcast()` pushes `notes.changed` to every window
252
+ ([ServiceCalls](../ServiceCalls.md), section 5). The timer waits for the changes to settle, as one save often causes
253
+ several of them.
254
+ - `destroy()` stops watching when the application stops.
255
+
256
+ ## 5. The controller
257
+
258
+ **`packages/app/src/controllers/NoteController.ts`**
259
+
260
+ ```typescript
261
+ import {Controller, type ServiceContext} from 'lakutata/com/entrypoint'
262
+ import {ipcEventOfContext} from 'lakutata/adapter/electron'
263
+ import {ServiceAction} from 'lakutata/decorator/ctrl'
264
+ import {Inject} from 'lakutata/decorator/di'
265
+ import type {BrowserWindow, IpcMainInvokeEvent, OpenDialogOptions, OpenDialogReturnValue} from 'electron'
266
+ import {type NoteInfo, Notes} from '../components/Notes.js'
267
+ import {NoteNameDTO, SaveNoteDTO} from '../dtos/NoteDTO.js'
268
+
269
+ /**
270
+ * The note actions, invoked by the window: window.ipc.invoke({cmd: 'notes.list'})
271
+ */
272
+ export class NoteController extends Controller {
273
+
274
+ @Inject('notes')
275
+ protected readonly notes: Notes
276
+
277
+ /**
278
+ * The notes folder, null before one is chosen
279
+ */
280
+ @ServiceAction({cmd: 'folder.get'})
281
+ public async folder(): Promise<string | null> {
282
+ return this.notes.folder
283
+ }
284
+
285
+ /**
286
+ * Ask the user for a folder in a system dialog, attached to the window that called; the folder stays the same when
287
+ * the dialog is canceled
288
+ */
289
+ @ServiceAction({cmd: 'folder.choose'})
290
+ public async choose(): Promise<string | null> {
291
+ //Electron is loaded by the actions that use it: the tests, run by Node.js, do not invoke them
292
+ const {BrowserWindow, dialog} = (await import('electron')).default
293
+ const event: IpcMainInvokeEvent | undefined = ipcEventOfContext<IpcMainInvokeEvent>(this.context as ServiceContext)
294
+ const window: BrowserWindow | null = event ? BrowserWindow.fromWebContents(event.sender) : null
295
+ const options: OpenDialogOptions = {title: 'The folder of the notes', properties: ['openDirectory', 'createDirectory']}
296
+ const result: OpenDialogReturnValue = window ? await dialog.showOpenDialog(window, options) : await dialog.showOpenDialog(options)
297
+ if (!result.canceled && result.filePaths.length) await this.notes.use(result.filePaths[0])
298
+ return this.notes.folder
299
+ }
300
+
301
+ /**
302
+ * The notes, the most recently modified first
303
+ */
304
+ @ServiceAction({cmd: 'notes.list'})
305
+ public async list(): Promise<NoteInfo[]> {
306
+ return await this.notes.list()
307
+ }
308
+
309
+ /**
310
+ * A note and its text
311
+ * @param input
312
+ */
313
+ @ServiceAction({cmd: 'notes.read'}, NoteNameDTO)
314
+ public async read(input: NoteNameDTO): Promise<{ name: string, text: string }> {
315
+ return {name: input.name, text: await this.notes.read(input.name)}
316
+ }
317
+
318
+ /**
319
+ * Write a note: the file <name>.md in the folder
320
+ * @param input
321
+ */
322
+ @ServiceAction({cmd: 'notes.save'}, SaveNoteDTO)
323
+ public async save(input: SaveNoteDTO): Promise<NoteInfo> {
324
+ return await this.notes.write(input.name, input.text)
325
+ }
326
+
327
+ /**
328
+ * Delete a note
329
+ * @param input
330
+ */
331
+ @ServiceAction({cmd: 'notes.delete'}, NoteNameDTO)
332
+ public async remove(input: NoteNameDTO): Promise<void> {
333
+ await this.notes.remove(input.name)
334
+ }
335
+
336
+ /**
337
+ * Show the file of a note in the system file manager (Finder, Explorer…)
338
+ * @param input
339
+ */
340
+ @ServiceAction({cmd: 'notes.reveal'}, NoteNameDTO)
341
+ public async reveal(input: NoteNameDTO): Promise<void> {
342
+ const {shell} = (await import('electron')).default
343
+ shell.showItemInFolder(this.notes.find(input.name))
344
+ }
345
+ }
346
+ ```
347
+
348
+ - Each `@ServiceAction` is called by a message: `{cmd: 'notes.save', name: 'Shopping', text: '- bread'}` calls `save()`
349
+ with `{name: 'Shopping', text: '- bread'}`, validated by `SaveNoteDTO` ([ServiceCalls](../ServiceCalls.md),
350
+ section 2). The actions are short: the work is done by the `notes` component, which the controller injects.
351
+ - `folder.choose` and `notes.reveal` use APIs that only the main process has: `dialog` and `shell`. Like the
352
+ `MainWindow` of the template, they import Electron only when they run, so the tests, which run with Node.js, never
353
+ load it.
354
+ - `ipcEventOfContext()` gives the IPC event of the request. Its `sender` is the page that called, and
355
+ `BrowserWindow.fromWebContents()` gives the window of that page, which the dialog belongs to (on macOS, a sheet
356
+ attached to the window). When the action is called over the HTTP entrypoint for development (section 9), there is no
357
+ IPC event, and the dialog belongs to no window.
358
+ - An action that returns nothing, such as `remove()`, answers `{success: true, error: null}`.
359
+
360
+ ## 6. The options of the application
361
+
362
+ **`packages/app/src/config/Config.ts`**
363
+
364
+ ```typescript
365
+ import type {ApplicationOptions} from 'lakutata'
366
+ import {buildEntrypoints, httpServiceEntrypoint} from 'lakutata/com/entrypoint'
367
+ import {electronIPCServiceEntrypoint, type IPCMain} from 'lakutata/adapter/electron'
368
+ import {MainWindow} from '../components/MainWindow.js'
369
+ import {Notes} from '../components/Notes.js'
370
+ import {NoteController} from '../controllers/NoteController.js'
371
+
372
+ export type ConfigOptions = {
373
+ /**
374
+ * The settings file, where the notes folder is remembered
375
+ */
376
+ settings: string
377
+ /**
378
+ * The directory of the main process, where the preload script and the built renderer are
379
+ */
380
+ directory?: string
381
+ /**
382
+ * The URL of the renderer in development (the Vite dev server); without it, the built renderer is loaded
383
+ */
384
+ renderer?: string
385
+ mode?: 'development' | 'production'
386
+ /**
387
+ * Whether to open the window, true by default (the tests serve the actions without a window)
388
+ */
389
+ window?: boolean
390
+ /**
391
+ * The ipcMain that serves the actions, Electron's by default (the tests pass their own)
392
+ */
393
+ ipcMain?: IPCMain
394
+ /**
395
+ * The port of an HTTP entrypoint for development, none by default. Tools (Postman, curl) invoke the actions with
396
+ * POST / and the message (the pattern and the input), receive the events pushed to them with GET /events
397
+ * (Server-Sent Events), and give their client id in the id header (or the id query parameter)
398
+ */
399
+ devPort?: number
400
+ }
401
+
402
+ /**
403
+ * The options of the application
404
+ * @param options
405
+ */
406
+ export function config(options: ConfigOptions): ApplicationOptions {
407
+ return {
408
+ id: 'notes',
409
+ name: 'notes',
410
+ mode: options.mode ?? 'development',
411
+ components: {
412
+ entrypoint: buildEntrypoints({
413
+ controllers: [NoteController],
414
+ //The renderer invokes the actions over IPC: window.ipc.invoke({cmd: 'notes.list'})
415
+ service: [
416
+ electronIPCServiceEntrypoint({ipcMain: options.ipcMain}),
417
+ ...options.devPort === undefined ? [] : [httpServiceEntrypoint({port: options.devPort})]
418
+ ]
419
+ }),
420
+ notes: {
421
+ class: Notes,
422
+ settings: options.settings
423
+ },
424
+ ...(options.window === false ? {} : {
425
+ window: {
426
+ class: MainWindow,
427
+ directory: options.directory,
428
+ renderer: options.renderer
429
+ }
430
+ })
431
+ },
432
+ //The actions are served, and the folder watched, before the window loads the renderer
433
+ bootstrap: options.window === false ? ['entrypoint', 'notes'] : ['entrypoint', 'notes', 'window']
434
+ }
435
+ }
436
+ ```
437
+
438
+ This is the `Config.ts` of the template, with:
439
+
440
+ - the required `settings` option, the settings file;
441
+ - the `notes` component, which receives that file, and the `NoteController` instead of the example controller;
442
+ - `bootstrap: ['entrypoint', 'notes', 'window']`: the actions are served and the folder is watched before the window
443
+ loads the renderer. The tests start the application without its window (`window: false`), with their own `ipcMain`.
444
+
445
+ ## 7. The main process
446
+
447
+ **`packages/app/src/App.ts`**
448
+
449
+ ```typescript
450
+ import {Application, resourcesPath, type ILogger} from 'lakutata'
451
+ import electron from 'electron'
452
+ import path from 'node:path'
453
+ import {fileURLToPath} from 'node:url'
454
+ import {config} from './config/Config.js'
455
+
456
+ /**
457
+ * The directory of the preload script and of the built renderer: the resources of the packaged application
458
+ * (lakutata.resources), or in development the directory of the main process (build/, with the renderer from the ui
459
+ * package)
460
+ */
461
+ const directory: string = resourcesPath() ?? path.dirname(fileURLToPath(import.meta.url))
462
+
463
+ /**
464
+ * The URL of the renderer in development (--renderer=<url>, the Vite dev server)
465
+ */
466
+ const renderer: string | undefined = process.argv.find((argument: string): boolean => argument.startsWith('--renderer='))?.slice('--renderer='.length)
467
+
468
+ //A single instance of the application: a second one exits, and the first one brings its window to the front
469
+ if (!electron.app.requestSingleInstanceLock()) {
470
+ electron.app.exit(0)
471
+ } else {
472
+ Application
473
+ .run(config({
474
+ //The notes folder is remembered in the data directory of the application
475
+ settings: path.join(electron.app.getPath('userData'), 'settings.json'),
476
+ directory: directory,
477
+ renderer: renderer,
478
+ mode: electron.app.isPackaged ? 'production' : 'development',
479
+ //In development, the actions are also served to tools (Postman, curl); the packaged application does not serve them
480
+ devPort: electron.app.isPackaged ? undefined : Number(process.env.DEV_PORT ?? 9000)
481
+ }))
482
+ .onLaunched((app: Application, log: ILogger): void => log.info('%s started in %s mode', app.appName, app.mode()))
483
+ .onFatalException((error: Error, log: ILogger): void => {
484
+ log.error('The application cannot start: %s', error.message)
485
+ electron.app.exit(1)
486
+ })
487
+ }
488
+ ```
489
+
490
+ One option is added to the `App.ts` of the template: `settings`, the file that remembers the notes folder, in the data
491
+ directory of the application (`app.getPath('userData')`). That directory is named after the `productName` of
492
+ `packages/app/package.json` (`notes`), in development as in the packaged application:
493
+ `~/Library/Application Support/notes` on macOS, `%APPDATA%\notes` on Windows and `~/.config/notes` on Linux.
494
+
495
+ The preload script of the template (`packages/app/src/preload.cts`) stays as it is: it gives the renderer the IPC
496
+ channel for the actions and the events, and nothing else from Electron or Node.js.
497
+
498
+ ## 8. The window
499
+
500
+ **`packages/ui/src/App.tsx`**
501
+
502
+ ```tsx
503
+ import {type FormEvent, type KeyboardEvent, useEffect, useState} from 'react'
504
+
505
+ /**
506
+ * A note of the folder, as the main process lists it
507
+ */
508
+ type NoteInfo = { name: string, modifiedAt: string }
509
+
510
+ export function App() {
511
+ const [folder, setFolder] = useState<string | null>(null)
512
+ const [notes, setNotes] = useState<NoteInfo[]>([])
513
+ const [current, setCurrent] = useState<string | null>(null)
514
+ const [text, setText] = useState<string>('')
515
+ const [newName, setNewName] = useState<string>('')
516
+ const [status, setStatus] = useState<string>('')
517
+
518
+ //Run an action of the main process; its error (a note not found, an invalid name) is shown under the note
519
+ const run = (action: () => Promise<void>): void => {
520
+ action().catch((error: Error): void => setStatus(error.message))
521
+ }
522
+ const list = (): void => run(async (): Promise<void> => setNotes(await window.ipc.invoke<NoteInfo[]>({cmd: 'notes.list'})))
523
+
524
+ useEffect((): () => void => {
525
+ run(async (): Promise<void> => setFolder(await window.ipc.invoke<string | null>({cmd: 'folder.get'})))
526
+ //Pushed by the main process when files in the folder change, whether saved here or by another program
527
+ window.ipc.on('notes.changed', list)
528
+ return (): void => {
529
+ window.ipc.off('notes.changed', list)
530
+ }
531
+ }, [])
532
+
533
+ useEffect((): void => {
534
+ if (folder) list()
535
+ }, [folder])
536
+
537
+ const choose = (): void => run(async (): Promise<void> => setFolder(await window.ipc.invoke<string | null>({cmd: 'folder.choose'})))
538
+
539
+ const open = (name: string): void => run(async (): Promise<void> => {
540
+ const note = await window.ipc.invoke<{ name: string, text: string }>({cmd: 'notes.read', name: name})
541
+ setCurrent(note.name)
542
+ setText(note.text)
543
+ setStatus('')
544
+ })
545
+
546
+ const create = (event: FormEvent): void => {
547
+ event.preventDefault()
548
+ run(async (): Promise<void> => {
549
+ await window.ipc.invoke({cmd: 'notes.save', name: newName, text: ''})
550
+ setCurrent(newName.trim())
551
+ setText('')
552
+ setNewName('')
553
+ setStatus('')
554
+ })
555
+ }
556
+
557
+ const save = (): void => run(async (): Promise<void> => {
558
+ await window.ipc.invoke({cmd: 'notes.save', name: current, text: text})
559
+ setStatus(`Saved at ${new Date().toLocaleTimeString()}`)
560
+ })
561
+
562
+ const remove = (): void => run(async (): Promise<void> => {
563
+ await window.ipc.invoke({cmd: 'notes.delete', name: current})
564
+ setCurrent(null)
565
+ setStatus('')
566
+ })
567
+
568
+ //Ctrl+S, or Cmd+S on macOS
569
+ const shortcut = (event: KeyboardEvent): void => {
570
+ if ((event.ctrlKey || event.metaKey) && event.key === 's') {
571
+ event.preventDefault()
572
+ save()
573
+ }
574
+ }
575
+
576
+ if (!folder) {
577
+ return (
578
+ <main style={{fontFamily: 'system-ui', padding: 32}}>
579
+ <p>The notes are Markdown files, in a folder of your choice.</p>
580
+ <button onClick={choose}>Choose a folder…</button>
581
+ <p>{status}</p>
582
+ </main>
583
+ )
584
+ }
585
+
586
+ return (
587
+ <main style={{fontFamily: 'system-ui', position: 'fixed', inset: 0, display: 'grid', gridTemplateColumns: '240px 1fr'}}>
588
+ <aside style={{borderRight: '1px solid #ddd', padding: 12, overflow: 'auto'}}>
589
+ <button onClick={choose} title={folder} style={{width: '100%'}}>{folder.split(/[\\/]/).pop()}</button>
590
+ <form onSubmit={create}>
591
+ <input value={newName} onChange={(event): void => setNewName(event.target.value)} placeholder="New note" style={{width: '100%', margin: '12px 0', boxSizing: 'border-box'}}/>
592
+ </form>
593
+ {notes.map((note: NoteInfo) => (
594
+ <div key={note.name} onClick={(): void => open(note.name)} style={{padding: 6, cursor: 'pointer', fontWeight: note.name === current ? 'bold' : 'normal'}}>
595
+ {note.name}
596
+ <div style={{fontSize: 11, color: '#888'}}>{new Date(note.modifiedAt).toLocaleString()}</div>
597
+ </div>
598
+ ))}
599
+ </aside>
600
+ <section style={{display: 'flex', flexDirection: 'column', padding: 12, gap: 8}}>
601
+ {current === null ? <p>Open a note, or create one.</p> : (
602
+ <>
603
+ <div style={{display: 'flex', gap: 8, alignItems: 'center'}}>
604
+ <strong style={{flex: 1}}>{current}</strong>
605
+ <button onClick={save}>Save</button>
606
+ <button onClick={(): void => run(() => window.ipc.invoke({cmd: 'notes.reveal', name: current}))}>Show in folder</button>
607
+ <button onClick={remove}>Delete</button>
608
+ </div>
609
+ <textarea value={text} onChange={(event): void => setText(event.target.value)} onKeyDown={shortcut} style={{flex: 1, fontFamily: 'ui-monospace, monospace', fontSize: 14}}/>
610
+ </>
611
+ )}
612
+ <small style={{color: '#888'}}>{status}</small>
613
+ </section>
614
+ </main>
615
+ )
616
+ }
617
+ ```
618
+
619
+ - `window.ipc` comes from the template (`packages/ui/src/ipc.ts`), through the preload script. `invoke()` sends a
620
+ message and returns the result of the action, or throws its error, `errno` included; `on()` and `off()` listen to
621
+ the events of the main process ([ServiceCalls](../ServiceCalls.md), section 4).
622
+ - The renderer is isolated from Node.js and Electron: it reads no files and opens no dialogs itself, but calls the
623
+ actions that do.
624
+ - On `notes.changed`, it lists the notes again: a note saved by an editor appears in the window.
625
+ - Errors, such as an invalid name, are shown under the note.
626
+
627
+ ## 9. Run it
628
+
629
+ ```bash
630
+ npm run dev
631
+ ```
632
+
633
+ The command starts the Vite dev server, which serves the renderer and reloads it whenever `packages/ui` changes, and
634
+ Electron, whose window loads the renderer with its DevTools open. The main process logs
635
+ `notes started in development mode`. The first time, the window asks you to choose a folder; after that, it opens the
636
+ folder remembered in the settings.
637
+
638
+ In development, the actions are also served over HTTP on port 9000 (`DEV_PORT`), so you can try them with curl or
639
+ Postman: `POST /` sends a message, and `GET /events` streams the events of a client as Server-Sent Events
640
+ ([ServiceCalls](../ServiceCalls.md), section 4). Once a folder is chosen in the window (here, one that already holds a
641
+ note `Ideas`), run in another terminal:
642
+
643
+ ```bash
644
+ curl -N 'localhost:9000/events?id=me' &
645
+ # event: client
646
+ # data: ["me"]
647
+
648
+ curl -X POST localhost:9000/ -H 'Content-Type: application/json' -d '{"cmd": "notes.save", "name": "Shopping", "text": "- bread\n- tea\n"}'
649
+ # {"success":true,"error":null,"payload":{"name":"Shopping","modifiedAt":"2026-10-17T14:57:41.391Z"}}
650
+ # event: notes.changed
651
+ # data: []
652
+
653
+ curl -X POST localhost:9000/ -H 'Content-Type: application/json' -d '{"cmd": "notes.list"}'
654
+ # {"success":true,"error":null,"payload":[{"name":"Shopping","modifiedAt":"2026-10-17T14:57:41.391Z"},{"name":"Ideas","modifiedAt":"2026-10-17T14:57:23.287Z"}]}
655
+
656
+ curl -X POST localhost:9000/ -H 'Content-Type: application/json' -d '{"cmd": "notes.read", "name": "Lunch"}'
657
+ # {"success":false,"error":{…,"message":"The note Lunch does not exist","errMsg":"The note Lunch does not exist","err":"NoteNotFoundException","errno":"E_NOTE_NOT_FOUND"},"payload":null}
658
+ ```
659
+
660
+ The window shows the new note at once, as the event reached it too. The error carries all the properties of the
661
+ exception, including its stack (left out here).
662
+
663
+ ## 10. Test it
664
+
665
+ The test of the template is replaced by a test of the notes. Like the test of the template, it runs the main process
666
+ with Node.js, without Electron or a window: its own `ipcMain` stands in for Electron's, and the test calls the actions
667
+ through it as the renderer does. The settings of the test remember a folder in a temporary directory.
668
+
669
+ **`packages/app/src/tests/App.spec.ts`**
670
+
671
+ ```typescript
672
+ import {after, before, describe, it} from 'node:test'
673
+ import assert from 'node:assert'
674
+ import {existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'
675
+ import {tmpdir} from 'node:os'
676
+ import path from 'node:path'
677
+ import {Application} from 'lakutata'
678
+ import type {IPCMain} from 'lakutata/adapter/electron'
679
+ import {config} from '../config/Config.js'
680
+
681
+ /**
682
+ * The ipcMain of the tests: through it, the actions are invoked as the renderer invokes them, without Electron
683
+ */
684
+ const handlers: Map<string, (event: unknown, data: unknown) => Promise<unknown>> = new Map()
685
+ const listeners: Map<string, (event: unknown, clientId: string) => void> = new Map()
686
+ const ipcMain: IPCMain = {
687
+ handle: (channel: string, listener: (event: unknown, data: unknown) => Promise<unknown>): unknown => handlers.set(channel, listener),
688
+ removeHandler: (channel: string): unknown => handlers.delete(channel),
689
+ on: (channel: string, listener: (event: unknown, clientId: string) => void): unknown => listeners.set(channel, listener),
690
+ removeAllListeners: (channel: string): unknown => listeners.delete(channel)
691
+ }
692
+ //The web contents of the renderer, which collects the events pushed to it
693
+ const received: unknown[][] = []
694
+ const sender = {once: (): void => undefined, off: (): void => undefined, send: (...args: unknown[]): unknown => received.push(args)}
695
+
696
+ /**
697
+ * Invoke an action as the renderer does (window.ipc.invoke)
698
+ * @param data
699
+ */
700
+ function invoke(data: Record<string, unknown>): Promise<any> {
701
+ return (handlers.get('lakutata:service') as (event: unknown, data: unknown, clientId: string) => Promise<unknown>)({sender: sender}, data, 'page')
702
+ }
703
+
704
+ /**
705
+ * Wait until a condition holds, 5 seconds at most
706
+ * @param condition
707
+ */
708
+ async function until(condition: () => boolean): Promise<void> {
709
+ for (let waited: number = 0; !condition(); waited += 50) {
710
+ if (waited >= 5000) throw new Error('Waited too long')
711
+ await new Promise((resolve: (value: unknown) => void): unknown => setTimeout(resolve, 50))
712
+ }
713
+ }
714
+
715
+ describe('notes', (): void => {
716
+
717
+ //The data directory of the application, and the notes folder that its settings remember
718
+ const data: string = mkdtempSync(path.join(tmpdir(), 'notes-'))
719
+ const folder: string = path.join(data, 'My notes')
720
+ let app: Application
721
+
722
+ before(async (): Promise<void> => {
723
+ mkdirSync(folder)
724
+ writeFileSync(path.join(data, 'settings.json'), JSON.stringify({folder: folder}))
725
+ app = await new Promise<Application>((resolve: (app: Application) => void): void => {
726
+ Application.run(config({settings: path.join(data, 'settings.json'), window: false, ipcMain: ipcMain})).onLaunched((launched: Application): void => resolve(launched))
727
+ })
728
+ })
729
+
730
+ after(async (): Promise<void> => {
731
+ await app?.dispose()
732
+ rmSync(data, {recursive: true, force: true})
733
+ })
734
+
735
+ it('gives the folder remembered by the settings', async (): Promise<void> => {
736
+ assert.deepStrictEqual(await invoke({cmd: 'folder.get'}), {success: true, error: null, payload: folder})
737
+ })
738
+
739
+ it('pushes notes.changed to the renderer when the files of the folder change', async (): Promise<void> => {
740
+ //The page subscribes to the events
741
+ listeners.get('lakutata:service:subscribe')!({sender: sender}, 'page')
742
+ //Another program writes a note
743
+ writeFileSync(path.join(folder, 'Ideas.md'), '# Ideas\n')
744
+ await until((): boolean => received.length > 0)
745
+ assert.deepStrictEqual(received[0], ['lakutata:service:event', 'notes.changed', []])
746
+ })
747
+
748
+ it('saves, lists, reads and deletes the notes', async (): Promise<void> => {
749
+ const saved = await invoke({cmd: 'notes.save', name: 'Shopping', text: '- bread\n- tea\n'})
750
+ assert.strictEqual(saved.payload.name, 'Shopping')
751
+ assert.strictEqual(readFileSync(path.join(folder, 'Shopping.md'), 'utf8'), '- bread\n- tea\n')
752
+ const listed = await invoke({cmd: 'notes.list'})
753
+ assert.deepStrictEqual(listed.payload.map((note: { name: string }): string => note.name), ['Shopping', 'Ideas'])
754
+ assert.deepStrictEqual((await invoke({cmd: 'notes.read', name: 'Shopping'})).payload, {name: 'Shopping', text: '- bread\n- tea\n'})
755
+ assert.strictEqual((await invoke({cmd: 'notes.delete', name: 'Shopping'})).success, true)
756
+ assert.strictEqual(existsSync(path.join(folder, 'Shopping.md')), false)
757
+ })
758
+
759
+ it('refuses a note that does not exist', async (): Promise<void> => {
760
+ const response = await invoke({cmd: 'notes.read', name: 'Missing'})
761
+ assert.strictEqual(response.success, false)
762
+ assert.strictEqual(response.error.errno, 'E_NOTE_NOT_FOUND')
763
+ })
764
+
765
+ it('refuses a name that is not a file name', async (): Promise<void> => {
766
+ const response = await invoke({cmd: 'notes.save', name: '../outside', text: ''})
767
+ assert.strictEqual(response.success, false)
768
+ assert.match(response.error.message, /file name/)
769
+ assert.strictEqual(existsSync(path.join(data, 'outside.md')), false)
770
+ })
771
+ })
772
+ ```
773
+
774
+ ```bash
775
+ npm test
776
+ ```
777
+
778
+ ```
779
+ app │ ✔ gives the folder remembered by the settings
780
+ app │ ✔ pushes notes.changed to the renderer when the files of the folder change
781
+ app │ ✔ saves, lists, reads and deletes the notes
782
+ app │ ✔ refuses a note that does not exist
783
+ app │ ✔ refuses a name that is not a file name
784
+ app │ ℹ pass 5
785
+ ✔ test ran in app
786
+ ```
787
+
788
+ `npm test` builds the packages, the renderer included, then runs the tests. The two actions that use Electron, the
789
+ dialog and the file manager, are tried out in the window.
790
+
791
+ ## 11. Package it
792
+
793
+ - `npm run package:host` builds the packages, then makes the installers for the platform it runs on, in
794
+ `dist/installer`: a `dmg` on macOS, a `deb` on Linux, an `nsis` installer and a portable executable on Windows.
795
+ `npm run package:linux` makes the Linux ones in Docker, from any platform. They are made by electron-builder, from
796
+ `lakutata.installer` in `packages/app/package.json` ([Installers](../Installers.md), section 8).
797
+ - The main process is bundled with its dependencies. The built renderer and the preload script go into the resources of
798
+ Electron (`lakutata.resources` in `packages/app/package.json`), where `resourcesPath()` finds them
799
+ ([Packaging](../Packaging.md), section 6).
800
+ - The packaged application has no HTTP entrypoint (`devPort` is given only when `app.isPackaged` is false), and keeps
801
+ its settings in the same data directory.
802
+
803
+ <!--
804
+ check:
805
+ create: notes electron-react
806
+ remove: packages/app/src/controllers/ExampleController.ts packages/app/src/dtos/GreetDTO.ts
807
+ run: npm test
808
+ run: npm run typecheck -w ui
809
+ -->