libmodulor 0.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 ADDED
@@ -0,0 +1,1485 @@
1
+ # libmodulor
2
+
3
+ An opinionated TypeScript library to create business oriented applications.
4
+
5
+ Applications created with `libmodulor` have **6 main properties** :
6
+
7
+ - Strictly typed with explicit business data types
8
+ - Fully typed e2e without code generation
9
+ - Auto documented
10
+ - Auto tested
11
+ - Multi platforms/runtimes
12
+ - Runnable anywhere
13
+
14
+ > [!WARNING]
15
+ > The project is still in active development. Although already used in pilot projects, it's not suitable for all production scenarios yet.
16
+ > Being developed by only one person, it may keep going for years or stop at any time.
17
+ > In the meantime, it's still a "research project" that needs improvement. Thus, it will be subject to BREAKING CHANGES as long as the version is not 1.0.0.
18
+
19
+ > [!NOTE]
20
+ > At the beginning, the whole documentation will be contained in this single file to make it easier to have the big picture and <kbd>cmd</kbd> + <kbd>F</kbd>. At some point, each section will move to a dedicated page. I'll even consider having a beautiful documentation website like all the cool kids.
21
+
22
+ All that said, the end goal is really to have a **production-grade library** to help everyone build **quality projects faster**. If you want to help in any way or have questions, feel free to contact me (cf. `author` in `package.json`).
23
+
24
+ ## Philosophy
25
+
26
+ One might argue that, with so many "JS frameworks" on the market, there are already too many ways to build new applications today. And they would be right.
27
+
28
+ That's why the angle taken by `libmodulor` is different. Although opinionated about some things (see below), it is not, regarding the technical side. Instead, it focuses mainly on the "core" of your application.
29
+
30
+ Thus, you are free to use :
31
+
32
+ - the data store of your choice (PostgreSQL, MySQL, MariaDB, DynamoDB, SQLite, MongoDB...),
33
+ - the frontend framework of your choice (React, Svelte, Angular, Vue, Solid...),
34
+ - the server of your choice (Express, Fastify, Hono...),
35
+ - the meta framework of your choice (Next, Remix, Astro, Nuxt...),
36
+ - the runtime of your choice (Node, Deno, Bun...)
37
+ - the libraries of your choice (Lodash, React Query...)
38
+ - the tools of your choice (Biome, ESLint, Prettier...)
39
+ - the styling library of your choice for web (tailwind, shadcn, bootstrap, vanilla CSS...)
40
+ - the hosting of your choice (Cloud, IaaS, PaaS, On-Prem, RaspberryPi, your fridge...)
41
+
42
+ The main goal is to offer higher level primitives that make building business applications faster, without having to use a boilerplate or worse, no/low code, and thus, avoid vendor lock-in.
43
+
44
+ ## How it works
45
+
46
+ The library defines a **4-layer architecture** composed of : `UseCase`, `App`, `Product`, `Target`.
47
+
48
+ ```mermaid
49
+ block-beta
50
+ Target1:2
51
+ Target2:2
52
+ Target3:2
53
+ columns 6
54
+ Product1:6
55
+ App1:3
56
+ App2:3
57
+ UseCase1
58
+ UseCase2
59
+ UseCase3
60
+ UseCase4
61
+ UseCase6
62
+ ```
63
+
64
+ ### UseCase
65
+
66
+ A use case is the smallest unit. It defines the contract, mainly as an `Input` that goes into lifecycle methods (`client` and/or `server`) to finally give an `Output`. In the end, it constitutes a piece of business functionality.
67
+
68
+ Inspired by [UML's Use case diagram](https://en.wikipedia.org/wiki/Use_case_diagram) and [Event-driven architecture](https://en.wikipedia.org/wiki/Event-driven_architecture), schematically, it could be defined as follows :
69
+
70
+ ```math
71
+ O = clientMain(serverMain(I))
72
+ ```
73
+
74
+ _Examples : `SignIn`, `CreatePost`, `TransferAccount`, `InviteContacts`_...
75
+
76
+ Note how it always starts with a verb.
77
+
78
+ ### App
79
+
80
+ An app is a logical group of use cases.
81
+
82
+ It's like a "module" (_whatever that means_), inspired by [Domain-driven design (DDD)](https://en.wikipedia.org/wiki/Domain-driven_design) bounded contexts.
83
+
84
+ _Examples : `Auth`, `Accounting`, `CMS`..._
85
+
86
+ ### Product
87
+
88
+ A product is a logical group of apps that are assembled together.
89
+
90
+ Behind this barbaric definition, it's simply what end users know and use.
91
+
92
+ _Examples : `GitHub`, `Facebook`, `LinkedIn`, `Airbnb`..._
93
+
94
+ When defined correctly, apps are reusable across multiple products (e.g. `Auth`).
95
+
96
+ ### Target
97
+
98
+ A target defines how a product is "exposed" to the end user. It's a combination of platform and runtime.
99
+
100
+ _Examples : `web-react`, `web-angular`, `server-node`, `cli-node`, `cli-stricli`..._
101
+
102
+ Note that it's the only place where the "infrastructure" choices are applied.
103
+
104
+ ## Getting Started
105
+
106
+ Enough theory, let's dive in and learn by doing.
107
+
108
+ > [!NOTE]
109
+ > This Guide is voluntarily very verbose and not scripted so you can get a full overview of how things work. `npx` magic is good. But understanding what happens behind the scenes is good as well.
110
+
111
+ In this Guide, we'll init a repository (a repository can contain multiple apps and products) and create a real life application using the `libmodulor` primitives.
112
+
113
+ We'll build a small trading application. It will contain one `App` named `Trading`, which will contain one `UseCase` named `BuyAsset`. The `App` will be mounted in a `Product` called `SuperTrader` which will be exposed via a `server` `Target`, a `web` `Target`, a `cli` `Target` and finally, a `mcp-server` `Target`.
114
+
115
+ > [!NOTE]
116
+ > MCP stands for [Model Context Protocol](https://modelcontextprotocol.io) introduced recently by [@anthropics](https://github.com/anthropics).
117
+
118
+ If we adapt the abstract mermaid chart displayed above, concretely, it looks like this :
119
+
120
+ ```mermaid
121
+ block-beta
122
+ server
123
+ web
124
+ cli
125
+ mcp_server
126
+ columns 4
127
+ SuperTrader:4
128
+ Trading:2
129
+ Auth:2
130
+ BuyAsset
131
+ ListOrders
132
+ SignUp
133
+ SignIn
134
+ ```
135
+
136
+ Note that we'll only develop one use case in this Guide but you get the idea.
137
+
138
+ ### Create the project
139
+
140
+ > [!IMPORTANT]
141
+ > At this stage, I'm following a documentation and spec first process. Thus, the source code is not yet published, just like the package on `npm`. Therefore, the commands below are not fully executable since `yarn install` will fail fetching the dependency. Currently, the main goal is to expose the docs, optimize the process, and most of all, get feedback on the mechanism. Thank you.
142
+
143
+ Assuming you have the following installed (otherwise, install them or adapt the commands) :
144
+
145
+ - `node` >= 22
146
+ - `yarn` >= 1.x
147
+ - `wget` and `curl`
148
+
149
+ If you're on macOS, for the `sed` commands, add a `''` after `-i` ([Explanation](https://stackoverflow.com/a/4247319/1259118)).
150
+
151
+ ```sh
152
+ # Create the directory
153
+ mkdir libmodulor-tuto && cd libmodulor-tuto # Note how the repository is generic to contain multiple apps and products
154
+
155
+ # Initialize git
156
+ git init
157
+
158
+ # Initialize config files
159
+ touch .gitignore biome.json package.json README.md tsconfig.json vitest.config.ts
160
+ ```
161
+
162
+ #### .gitignore
163
+
164
+ ```.gitignore
165
+ coverage
166
+ dist
167
+ node_modules
168
+ src/apps/**/test/reports
169
+ .env
170
+ ```
171
+
172
+ #### biome.json
173
+
174
+ ```json
175
+ {
176
+ "$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
177
+ "files": {
178
+ "ignore": ["coverage", "dist", "node_modules"],
179
+ "ignoreUnknown": true
180
+ },
181
+ "formatter": {
182
+ "indentStyle": "space",
183
+ "indentWidth": 4
184
+ },
185
+ "javascript": {
186
+ "formatter": {
187
+ "quoteStyle": "single"
188
+ },
189
+ "parser": {
190
+ "unsafeParameterDecoratorsEnabled": true
191
+ }
192
+ }
193
+ }
194
+ ```
195
+
196
+ #### package.json
197
+
198
+ ```json
199
+ {
200
+ "name": "libmodulor-tuto",
201
+ "version": "0.1.0",
202
+ "author": "Chafik H'nini <chafik.hnini@gmail.com>",
203
+ "type": "module",
204
+ "private": true,
205
+ "scripts": {
206
+ "cli": "node ./node_modules/libmodulor/dist/esm/products/Wizard/index.js",
207
+ "lint": "biome check --write .",
208
+ "test": "tsc && vitest run"
209
+ },
210
+ "dependencies": {
211
+ "inversify": "^6.2.1",
212
+ "libmodulor": "c100k/libmodulor#master",
213
+ "reflect-metadata": "^0.2.2"
214
+ },
215
+ "devDependencies": {
216
+ "@biomejs/biome": "^1.9.4",
217
+ "@types/node": "^22.10.2",
218
+ "@vitest/coverage-v8": "^2.1.1",
219
+ "buffer": "^6.0.3",
220
+ "cookie-parser": "^1.4.7",
221
+ "express": "^4.21.2",
222
+ "express-fileupload": "^1.5.1",
223
+ "fast-check": "^3.23.2",
224
+ "helmet": "^8.0.0",
225
+ "jose": "^5.9.6",
226
+ "typescript": "^5.7.2",
227
+ "vite": "^6.0.5",
228
+ "vitest": "^2.1.8"
229
+ }
230
+ }
231
+ ```
232
+
233
+ #### README.md
234
+
235
+ ```md
236
+ # libmodulor-tuto
237
+
238
+ 🚀🚀🚀
239
+ ```
240
+
241
+ #### tsconfig.json
242
+
243
+ ```json
244
+ {
245
+ "compilerOptions": {
246
+ "allowSyntheticDefaultImports": true,
247
+ "declaration": true,
248
+ "lib": ["dom", "esnext"],
249
+ "module": "NodeNext",
250
+ "moduleResolution": "NodeNext",
251
+ "noEmit": true,
252
+ "removeComments": true,
253
+ "skipLibCheck": true,
254
+ "sourceMap": true,
255
+ "target": "ESNext",
256
+
257
+ "strict": true,
258
+ "allowUnreachableCode": false,
259
+ "allowUnusedLabels": false,
260
+ "exactOptionalPropertyTypes": true,
261
+ "noFallthroughCasesInSwitch": true,
262
+ "noPropertyAccessFromIndexSignature": true,
263
+ "noImplicitOverride": true,
264
+ "noImplicitReturns": true,
265
+ "noUncheckedIndexedAccess": true,
266
+ "noUnusedLocals": true,
267
+ "noUnusedParameters": true,
268
+ "verbatimModuleSyntax": true,
269
+
270
+ "emitDecoratorMetadata": true,
271
+ "experimentalDecorators": true,
272
+
273
+ "jsx": "react"
274
+ }
275
+ }
276
+ ```
277
+
278
+ #### vitest.config.ts
279
+
280
+ ```typescript
281
+ import { defineConfig } from 'vitest/config';
282
+
283
+ export default defineConfig({
284
+ test: {
285
+ coverage: {
286
+ enabled: true,
287
+ exclude: ['src/apps/**/test', 'src/**/*.test.ts'],
288
+ include: ['src'],
289
+ reporter: ['html', 'lcov', 'text'],
290
+ },
291
+ reporters: ['verbose'],
292
+ },
293
+ });
294
+ ```
295
+
296
+ #### Install
297
+
298
+ ```sh
299
+ yarn install
300
+ ```
301
+
302
+ ```sh
303
+ yarn lint && git add . && git commit -m "chore: init source code"
304
+ ```
305
+
306
+ Optionally, you can create a remote repository (e.g. on GitHub) and push it.
307
+
308
+ ### Create the App
309
+
310
+ An app is composed of three main files : `i18n.ts`, `manifest.ts` and `index.ts`.
311
+
312
+ ```sh
313
+ mkdir -p src/apps/Trading/src/ucds
314
+ touch src/apps/Trading/src/{i18n.ts,manifest.ts}
315
+ touch src/apps/Trading/index.ts
316
+ ```
317
+
318
+ > [!NOTE]
319
+ > There is a lot of controversy about barrel files. In this specific context, they are useful to only expose the necessary things to the upper layers and keep the app isolated.
320
+
321
+ #### i18n.ts
322
+
323
+ ```typescript
324
+ import type { AppI18n } from 'libmodulor';
325
+
326
+ export const I18n: AppI18n = {
327
+ en: {},
328
+ };
329
+ ```
330
+
331
+ #### manifest.ts
332
+
333
+ ```typescript
334
+ import type { AppManifest } from 'libmodulor';
335
+
336
+ export const Manifest = {
337
+ languageCodes: ['en'],
338
+ name: 'Trading',
339
+ ucReg: {},
340
+ } satisfies AppManifest;
341
+ ```
342
+
343
+ #### index.ts
344
+
345
+ ```typescript
346
+ // Expose only what's necessary
347
+
348
+ export { I18n } from './src/i18n.js';
349
+ export { Manifest } from './src/manifest.js';
350
+ ```
351
+
352
+ ```sh
353
+ yarn lint && git add . && git commit -m "feat: add the app"
354
+ ```
355
+
356
+ ### Create the UseCase
357
+
358
+ > [!NOTE]
359
+ > Starting now, you'll see `UC` or `uc` a lot. It's the abbreviation of `UseCase`. Acronyms are not good in codebases, except those that are commonly used ([debate](https://stackoverflow.com/questions/2236807/java-naming-convention-with-acronyms)). In any case, when you write `UseCase` hundreds of times, you're happy to be able to write `UC` instead. Thus, `UCD` stands for `Use Case Definition`, `UCIF` stands for `Use Case Input Field` and so on.
360
+
361
+ The app manifest registers all the use cases metadata. Use cases can depend on each other as we'll see later, and this dependency must go through the manifest. Never directly.
362
+
363
+ Update `manifest.ts` to register the new use case.
364
+
365
+ ```typescript
366
+ // ...
367
+ ucReg: {
368
+ BuyAsset: {
369
+ action: 'Create',
370
+ icon: 'plus',
371
+ name: 'BuyAsset',
372
+ },
373
+ },
374
+ // ...
375
+ ```
376
+
377
+ If you're using an IDE with auto-complete, you might have noticed the other properties like `beta`, `new`, `sensitive`. We'll come back to them later.
378
+
379
+ ```sh
380
+ mkdir src/apps/Trading/src/dt
381
+ touch src/apps/Trading/src/dt/TISIN.ts
382
+ touch src/apps/Trading/src/ucds/{BuyAssetServerMain.ts,BuyAssetUCD.ts}
383
+ ```
384
+
385
+ #### TISIN.ts
386
+
387
+ An asset is usually identified by a unique code called [ISIN](https://www.isin.org). This is a typical business data type that has specific rules and that is not simply a `string`.
388
+
389
+ ```typescript
390
+ import { type TName, TString, type TStringConstraints } from 'libmodulor';
391
+
392
+ export type ISIN = Capitalize<string>;
393
+
394
+ export class TISIN extends TString<ISIN, 'ISIN'> {
395
+ public static readonly FORMAT: RegExp = /^[A-Z]{2}[A-Z0-9]{9}[0-9]$/;
396
+
397
+ constructor(constraints?: TStringConstraints) {
398
+ super({
399
+ ...constraints,
400
+ format: { f: 'ISIN', regexp: TISIN.FORMAT },
401
+ });
402
+ }
403
+
404
+ public override tName(): TName {
405
+ return 'ISIN';
406
+ }
407
+
408
+ public override example(): ISIN {
409
+ return 'US02079K3059';
410
+ }
411
+ }
412
+ ```
413
+
414
+ #### BuyAssetUCD.ts
415
+
416
+ ```typescript
417
+ import {
418
+ type AggregateOPI0,
419
+ type Amount,
420
+ EverybodyUCPolicy,
421
+ TAmount,
422
+ TBoolean,
423
+ TUIntQuantity,
424
+ type UCDef,
425
+ type UCInput,
426
+ type UCInputFieldValue,
427
+ type UCMain,
428
+ type UCMainInput,
429
+ type UCOutputOrNothing,
430
+ type UCTransporter,
431
+ type UIntQuantity,
432
+ } from 'libmodulor';
433
+ import { inject, injectable } from 'inversify';
434
+
435
+ import { Manifest } from '../manifest.js';
436
+
437
+ import { type ISIN, TISIN } from '../dt/TISIN.js';
438
+ import { BuyAssetServerMain } from './BuyAssetServerMain.js';
439
+
440
+ export interface BuyAssetInput extends UCInput {
441
+ isin: UCInputFieldValue<ISIN>;
442
+ limit: UCInputFieldValue<Amount>;
443
+ qty: UCInputFieldValue<UIntQuantity>;
444
+ }
445
+
446
+ export interface BuyAssetOPI0 extends AggregateOPI0 {
447
+ executedDirectly: boolean;
448
+ }
449
+
450
+ @injectable()
451
+ class BuyAssetClientMain implements UCMain<BuyAssetInput, BuyAssetOPI0> {
452
+ constructor(
453
+ @inject('UCTransporter')
454
+ private ucTransporter: UCTransporter,
455
+ ) {}
456
+
457
+ public async exec({
458
+ uc,
459
+ }: UCMainInput<BuyAssetInput, BuyAssetOPI0>): Promise<
460
+ UCOutputOrNothing<BuyAssetOPI0>
461
+ > {
462
+ return this.ucTransporter.send(uc);
463
+ }
464
+ }
465
+
466
+ export const BuyAssetUCD: UCDef<BuyAssetInput, BuyAssetOPI0> = {
467
+ io: {
468
+ input: {
469
+ fields: {
470
+ isin: {
471
+ type: new TISIN(),
472
+ },
473
+ limit: {
474
+ type: new TAmount('USD'),
475
+ },
476
+ qty: {
477
+ type: new TUIntQuantity(),
478
+ },
479
+ },
480
+ },
481
+ output: {
482
+ parts: {
483
+ _0: {
484
+ fields: {
485
+ executedDirectly: {
486
+ type: new TBoolean(),
487
+ },
488
+ },
489
+ },
490
+ },
491
+ },
492
+ },
493
+ lifecycle: {
494
+ client: {
495
+ main: BuyAssetClientMain,
496
+ policy: EverybodyUCPolicy,
497
+ },
498
+ server: {
499
+ main: BuyAssetServerMain,
500
+ policy: EverybodyUCPolicy,
501
+ },
502
+ },
503
+ metadata: Manifest.ucReg.BuyAsset,
504
+ };
505
+ ```
506
+
507
+ #### BuyAssetServerMain.ts
508
+
509
+ ```typescript
510
+ import {
511
+ type UCMain,
512
+ type UCMainInput,
513
+ type UCManager,
514
+ type UCOutput,
515
+ UCOutputBuilder,
516
+ } from 'libmodulor';
517
+ import { inject, injectable } from 'inversify';
518
+
519
+ import type { BuyAssetInput, BuyAssetOPI0 } from './BuyAssetUCD.js';
520
+
521
+ @injectable()
522
+ export class BuyAssetServerMain implements UCMain<BuyAssetInput, BuyAssetOPI0> {
523
+ constructor(@inject('UCManager') private ucManager: UCManager) {}
524
+
525
+ public async exec({
526
+ uc,
527
+ }: UCMainInput<BuyAssetInput, BuyAssetOPI0>): Promise<
528
+ UCOutput<BuyAssetOPI0>
529
+ > {
530
+ // >=> Persist the order
531
+ const { aggregateId } = await this.ucManager.persist(uc);
532
+
533
+ // >=> TODO : Check the user has enough funds to place the order
534
+
535
+ // >=> TODO : Send the order to a queue for processing
536
+ const executedDirectly: BuyAssetOPI0['executedDirectly'] = false;
537
+
538
+ return new UCOutputBuilder<BuyAssetOPI0>()
539
+ .add({
540
+ executedDirectly,
541
+ id: aggregateId,
542
+ })
543
+ .get();
544
+ }
545
+ }
546
+ ```
547
+
548
+ For now, we won't detail all this code but take the time to read it and understand how it works. Hopefully it's clear enough and self-explanatory.
549
+
550
+ > [!TIP]
551
+ > Using a comment following the pattern `// >=> ` in `ClientMain` and `ServerMain` has a specific meaning as we'll see a little bit later.
552
+
553
+ > [!NOTE]
554
+ > Unlike `ClientMain`, `ServerMain` is put in another file for "historical" reasons, mainly for stripping and tree shaking reasons. More on this later.
555
+
556
+ ```sh
557
+ yarn lint && git add . && git commit -m "feat: add the use case"
558
+ ```
559
+
560
+ ### Test the App
561
+
562
+ #### Preliminary test
563
+
564
+ By default, we rely on [vitest](https://vitest.dev) to run the tests and [@vitest/coverage-v8](https://vitest.dev/guide/coverage) for the coverage.
565
+
566
+ ```sh
567
+ yarn test
568
+ ```
569
+
570
+ ```sh
571
+ % Coverage report from v8
572
+ ------------------------|---------|----------|---------|---------|-------------------
573
+ File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
574
+ ------------------------|---------|----------|---------|---------|-------------------
575
+ All files | 0 | 40 | 40 | 0 |
576
+ Trading | 0 | 0 | 0 | 0 |
577
+ index.ts | 0 | 0 | 0 | 0 | 1
578
+ Trading/src | 0 | 100 | 100 | 0 |
579
+ i18n.ts | 0 | 100 | 100 | 0 | 3-5
580
+ manifest.ts | 0 | 100 | 100 | 0 | 3-14
581
+ Trading/src/ucds | 0 | 0 | 0 | 0 |
582
+ BuyAssetServerMain.ts | 0 | 0 | 0 | 0 | 1-28
583
+ BuyAssetUCD.ts | 0 | 0 | 0 | 0 | 1-92
584
+ ------------------------|---------|----------|---------|---------|-------------------
585
+ ```
586
+
587
+ As expected, the coverage report is pretty lame. Which is understandable, since we haven't written any tests yet.
588
+
589
+ ```sh
590
+ mkdir src/apps/Trading/test
591
+ touch src/apps/Trading/test/Configurator.ts
592
+ ```
593
+
594
+ #### Configurator.ts
595
+
596
+ ```typescript
597
+ import { type AppTesterCtx, type CryptoManager, bindCommon } from 'libmodulor';
598
+ import {
599
+ NodeDeterministicCryptoManager,
600
+ bindNodeCore,
601
+ bindServer,
602
+ } from 'libmodulor/node';
603
+ import { SimpleAppTesterConfigurator } from 'libmodulor/node-test';
604
+ import { injectable } from 'inversify';
605
+
606
+ @injectable()
607
+ export class Configurator extends SimpleAppTesterConfigurator {
608
+ public override async bindImplementations(
609
+ ctx: AppTesterCtx,
610
+ ): Promise<void> {
611
+ await super.bindImplementations(ctx);
612
+
613
+ const { container } = ctx;
614
+
615
+ bindCommon(container);
616
+ bindNodeCore(container);
617
+ bindServer(container);
618
+
619
+ container
620
+ .rebind<CryptoManager>('CryptoManager')
621
+ .to(NodeDeterministicCryptoManager);
622
+ }
623
+ }
624
+ ```
625
+
626
+ #### Automated test
627
+
628
+ Generate the automated tests and execute them with the CLI (it does more than a simple `yarn test`).
629
+
630
+ ```sh
631
+ yarn cli GenerateAppsTests
632
+ yarn cli TestApp --appName Trading
633
+ ```
634
+
635
+ ```sh
636
+ % Coverage report from v8
637
+ ------------------------|---------|----------|---------|---------|-------------------
638
+ File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
639
+ ------------------------|---------|----------|---------|---------|-------------------
640
+ All files | 98.83 | 80 | 80 | 98.83 |
641
+ Trading | 0 | 0 | 0 | 0 |
642
+ index.ts | 0 | 0 | 0 | 0 | 1
643
+ Trading/src | 100 | 100 | 100 | 100 |
644
+ i18n.ts | 100 | 100 | 100 | 100 |
645
+ manifest.ts | 100 | 100 | 100 | 100 |
646
+ Trading/src/ucds | 100 | 100 | 100 | 100 |
647
+ BuyAssetServerMain.ts | 100 | 100 | 100 | 100 |
648
+ BuyAssetUCD.ts | 100 | 100 | 100 | 100 |
649
+ ------------------------|---------|----------|---------|---------|-------------------
650
+ 2024-12-29T11:00:53.178Z [info] Coverage Report => open src/apps/Trading/test/reports/coverage/index.html
651
+ 2024-12-29T11:00:53.178Z [info] Simple HTML Report => open src/apps/Trading/test/reports/simple-html/index.html
652
+ ```
653
+
654
+ That's much better. Without writing any line of test code, we've reached almost 100% coverage. Although coverage is a vanity metric in some way, it still is a valuable one.
655
+
656
+ Note another important thing : auto documentation. Check out the generated `src/apps/Trading/README.md` that shows a mermaid chart for each use case and a technical summary. This is very valuable to whoever wants to understand what the app does.
657
+
658
+ To vizualize the mermaid chart, you can copy/paste it [here](https://mermaid.live) or if you've published your repository to GitHub, they are displayed out of the box.
659
+
660
+ <img src="/docs/assets/trading-buy-asset-sequence-diagram.png" width="600px">
661
+
662
+ You can see how the special comments we've mentioned earlier show up in this chart. It's nice in order to describe with more details what happens at each step.
663
+
664
+ Note also the generated "Coverage Report" and the "Simple HTML Report". The former is provided by `c8` while the other is built by `libmodulor`. It gives a great overview of the test scenarios.
665
+
666
+ > [!NOTE]
667
+ > You might have seen that `Configurator` is extensible. It allows you to define specific flows to test a suite of use cases, define specific assertions, etc.
668
+
669
+ ```sh
670
+ yarn lint && git add . && git commit -m "test: init app tests"
671
+ ```
672
+
673
+ ### Create the Product
674
+
675
+ Like the app, the product has a `i18n.ts` and `manifest.ts`.
676
+
677
+ ```sh
678
+ mkdir -p src/products/SuperTrader
679
+ touch src/products/SuperTrader/{i18n.ts,manifest.ts}
680
+ ```
681
+
682
+ #### i18n.ts
683
+
684
+ ```typescript
685
+ import type { ProductI18n } from 'libmodulor';
686
+ import { I18nEN } from 'libmodulor/locales/en';
687
+
688
+ import { I18n as TradingI18n } from '../../apps/Trading/index.js';
689
+
690
+ export const I18n: ProductI18n = {
691
+ en: {
692
+ ...I18nEN,
693
+ ...TradingI18n.en,
694
+ p_desc: 'A simple app to trade crypto, shares and other assets',
695
+ p_slogan: 'Trading made simple',
696
+ },
697
+ };
698
+ ```
699
+
700
+ #### manifest.ts
701
+
702
+ ```typescript
703
+ import type { ProductManifest } from 'libmodulor';
704
+
705
+ export const Manifest: ProductManifest = {
706
+ appReg: [{ name: 'Trading' }],
707
+ name: 'SuperTrader',
708
+ };
709
+ ```
710
+
711
+ > [!NOTE]
712
+ > The same way we register use cases in an app, we register apps in a product. Since apps are reusable across products, you can even exclude some use cases for a given product.
713
+
714
+ ```sh
715
+ yarn lint && git add . && git commit -m "feat: add the product"
716
+ ```
717
+
718
+ ### Create the server Target
719
+
720
+ We'll use the pre-built [express](https://expressjs.com) `ServerManager`.
721
+
722
+ ```sh
723
+ mkdir src/products/SuperTrader/server
724
+ touch src/products/SuperTrader/server/{container.ts,index.ts}
725
+ touch tsconfig.build.json
726
+ touch .env
727
+ ```
728
+
729
+ #### container.ts
730
+
731
+ ```typescript
732
+ import {
733
+ CONTAINER_OPTS,
734
+ EnvSettingsManager,
735
+ type ServerManager,
736
+ type ServerManagerSettings,
737
+ type SettingsManager,
738
+ TARGET_DEFAULT_SERVER_MANAGER_SETTINGS,
739
+ bindCommon,
740
+ bindProduct,
741
+ } from 'libmodulor';
742
+ import {
743
+ NodeExpressServerManager,
744
+ bindNodeCore,
745
+ bindServer,
746
+ } from 'libmodulor/node';
747
+ import { Container } from 'inversify';
748
+
749
+ import { I18n } from '../i18n.js';
750
+ import { Manifest } from '../manifest.js';
751
+
752
+ type S = ServerManagerSettings;
753
+
754
+ const container = new Container(CONTAINER_OPTS);
755
+
756
+ bindCommon<S>(container, () => ({
757
+ ...TARGET_DEFAULT_SERVER_MANAGER_SETTINGS,
758
+ }));
759
+ bindNodeCore(container);
760
+ bindServer(container);
761
+ bindProduct(container, Manifest, I18n);
762
+
763
+ container.rebind<SettingsManager>('SettingsManager').to(EnvSettingsManager);
764
+
765
+ container.bind<ServerManager>('ServerManager').to(NodeExpressServerManager);
766
+
767
+ export default container;
768
+ ```
769
+
770
+ #### index.ts
771
+
772
+ ```typescript
773
+ import {
774
+ APPS_ROOT_DIR_NAME,
775
+ type FSManager,
776
+ type I18nManager,
777
+ ServerBooter,
778
+ } from 'libmodulor';
779
+
780
+ import container from './container.js';
781
+
782
+ await container.get<I18nManager>('I18nManager').init();
783
+
784
+ await container.resolve(ServerBooter).exec({
785
+ appsRootPath: container
786
+ .get<FSManager>('FSManager')
787
+ .path('..', '..', '..', APPS_ROOT_DIR_NAME),
788
+ srcImporter: (path) => import(path),
789
+ });
790
+ ```
791
+
792
+ #### tsconfig.build.json
793
+
794
+ ```json
795
+ {
796
+ "extends": "./tsconfig.json",
797
+ "compilerOptions": {
798
+ "noEmit": false,
799
+ "outDir": "dist"
800
+ },
801
+ "include": ["src"]
802
+ }
803
+ ```
804
+
805
+ #### .env
806
+
807
+ ```properties
808
+ app_logger_level=trace # the default is 'debug'
809
+ ```
810
+
811
+ > [!TIP]
812
+ > A setting named `my_setting` in the code can be overriden with an environment variable called `app_my_setting`.
813
+
814
+ #### Build & Run
815
+
816
+ Update `package.json` to add new entries to the `scripts`.
817
+
818
+ ```json
819
+ "build": "tsc --project tsconfig.build.json && cp .env dist/products/SuperTrader/server/.env",
820
+ "run:server": "cd dist/products/SuperTrader/server && node --env-file .env index.js",
821
+ ```
822
+
823
+ ```sh
824
+ yarn build && yarn run:server
825
+ ```
826
+
827
+ Et voilà ! The server is running !
828
+
829
+ ```sh
830
+ curl -X POST -H "Content-Type: application/json" http://localhost:7443/api/v1/BuyAsset
831
+ # ❌ {"message":"Invalid credentials"}
832
+ curl -X POST -H "Content-Type: application/json" -H "X-API-Key: PublicApiKeyToBeChangedWhenDeploying" http://localhost:7443/api/v1/BuyAsset
833
+ # ❌ {"message":"isin must be filled"}
834
+ curl -X POST -H "Content-Type: application/json" -H "X-API-Key: PublicApiKeyToBeChangedWhenDeploying" -d '{"isin":"US02079K3059","limit":123.5,"qty":150}' http://localhost:7443/api/v1/BuyAsset
835
+ # ✅ {"parts":{"_0":{"items":[{"executedDirectly":false,"id":"95dddca5-5e9d-48ac-a90c-71a58d4e8554"}],"total":1}}}
836
+ ```
837
+
838
+ As you can see, validation comes out of the box. Later we'll see how to add even more precise rules to the data types.
839
+
840
+ > [!NOTE]
841
+ > The `public_api_key` is just a first layer of security to "authenticate" the client apps calling the server. Hopefully this is not the only security mechanism because of course, this key must be present in clear client side (web, cli, curl...). We'll dive deeper in security when we study the policies.
842
+
843
+ ```sh
844
+ yarn lint && git add . && git commit -m "feat: add the server target"
845
+ ```
846
+
847
+ ### Create the web Target
848
+
849
+ We'll use the pre-built [React](https://react.dev) components to build a SPA (Single Page Application), bundled with [vite](https://vite.dev) and served with the server defined above.
850
+
851
+ > [!WARNING]
852
+ > For readers used to "beautiful" websites à la Linear, Vercel and related, your eyes will burn. You're going to discover the simple and pure CSS-less Web. The most beautiful one.
853
+ > Of course, feel free to add CSS if you want to. The main goal here is to focus on the essence of the UI and not the UI design.
854
+
855
+ ```sh
856
+ yarn add --dev "@types/react@^18.3.17" "@types/react-dom@^18.3.5"
857
+ yarn add "react@^18.3.1" "react-dom@^18.3.1"
858
+
859
+ mkdir -p src/products/SuperTrader/web/components
860
+ touch src/products/SuperTrader/vite.config.web.ts
861
+ touch src/products/SuperTrader/web/{container.ts,index.html,index.tsx}
862
+ touch src/products/SuperTrader/web/components/App.tsx
863
+ ```
864
+
865
+ #### vite.config.web.ts
866
+
867
+ ```typescript
868
+ import { join } from 'node:path';
869
+
870
+ import { StripUCDLifecycleServerPlugin } from 'libmodulor/vite';
871
+ import { defineConfig } from 'vite';
872
+
873
+ const base = process.cwd();
874
+ const root = join('src', 'products', 'SuperTrader', 'web');
875
+ const outDir = join(
876
+ base,
877
+ 'dist',
878
+ 'products',
879
+ 'SuperTrader',
880
+ 'server',
881
+ 'public',
882
+ );
883
+
884
+ export default defineConfig({
885
+ build: {
886
+ emptyOutDir: true,
887
+ outDir,
888
+ },
889
+ plugins: [StripUCDLifecycleServerPlugin],
890
+ root,
891
+ });
892
+ ```
893
+
894
+ #### container.ts
895
+
896
+ ```typescript
897
+ import {
898
+ CONTAINER_OPTS,
899
+ type ServerClientManagerSettings,
900
+ TARGET_DEFAULT_SERVER_CLIENT_MANAGER_SETTINGS,
901
+ bindCommon,
902
+ bindProduct,
903
+ } from 'libmodulor';
904
+ import { bindWeb } from 'libmodulor/web';
905
+ import { Container } from 'inversify';
906
+
907
+ import { I18n } from '../i18n.js';
908
+ import { Manifest } from '../manifest.js';
909
+
910
+ type S = ServerClientManagerSettings;
911
+
912
+ const container = new Container(CONTAINER_OPTS);
913
+
914
+ bindCommon<S>(container, () => ({
915
+ ...TARGET_DEFAULT_SERVER_CLIENT_MANAGER_SETTINGS,
916
+ }));
917
+ bindWeb(container);
918
+ bindProduct(container, Manifest, I18n);
919
+
920
+ export default container;
921
+ ```
922
+
923
+ #### index.html
924
+
925
+ ```html
926
+ <!DOCTYPE html>
927
+ <html lang="en">
928
+ <head>
929
+ <meta charset="utf-8" />
930
+ <meta content="width=device-width, initial-scale=1" name="viewport"
931
+ />
932
+ </head>
933
+ <body>
934
+ <div id="root"></div>
935
+ <script type="module" src="/index.tsx"></script>
936
+ </body>
937
+ </html>
938
+ ```
939
+
940
+ #### App.tsx
941
+
942
+ Update `src/apps/Trading/index.ts` to expose the use case.
943
+
944
+ ```typescript
945
+ export { BuyAssetUCD } from './src/ucds/BuyAssetUCD.js';
946
+ ```
947
+
948
+ Naturally, in real life scenarios, we would never have such a bloated `App.tsx` and we could create fine-grained components. Everybody does that, right ?
949
+
950
+ ```tsx
951
+ import { type Logger, type ProductManifest, UCOutputReader } from 'libmodulor';
952
+ import {
953
+ UCPanel,
954
+ type UCPanelOnError,
955
+ useDIContext,
956
+ useUC,
957
+ useUCOR,
958
+ } from 'libmodulor/react';
959
+ import {
960
+ UCAutoExecLoader,
961
+ UCExecTouchable,
962
+ UCForm,
963
+ } from 'libmodulor/react-web-pure';
964
+ import React, { useEffect, useState, type ReactElement } from 'react';
965
+
966
+ import { BuyAssetUCD, Manifest } from '../../../../apps/Trading/index.js';
967
+
968
+ export default function App(): ReactElement {
969
+ const { container, i18nManager, wordingManager } = useDIContext();
970
+ const [logger] = useState(container.get<Logger>('Logger'));
971
+ const [productManifest] = useState(
972
+ container.get<ProductManifest>('ProductManifest'),
973
+ );
974
+
975
+ const [buyAssetUC] = useUC(Manifest, BuyAssetUCD, null);
976
+ const [buyAssetPart0, _buyAssetPart1, { append0 }] = useUCOR(
977
+ new UCOutputReader(BuyAssetUCD, undefined),
978
+ );
979
+
980
+ const [loading, setLoading] = useState(true);
981
+
982
+ useEffect(() => {
983
+ (async () => {
984
+ logger.debug('Initializing i18n');
985
+ await i18nManager.init();
986
+ logger.debug('Done initializing i18n');
987
+ setLoading(false);
988
+ })();
989
+
990
+ const { slogan } = wordingManager.p();
991
+ document.title = `${productManifest.name} : ${slogan}`;
992
+ }, [i18nManager, logger, productManifest, wordingManager]);
993
+
994
+ const onError: UCPanelOnError = async (err) => alert(err.message);
995
+
996
+ const { slogan } = wordingManager.p();
997
+ const { label } = wordingManager.uc(buyAssetUC.def);
998
+ const { label: idLabel } = wordingManager.ucof('id');
999
+ const { label: executedDirectlyLabel } =
1000
+ wordingManager.ucof('executedDirectly');
1001
+
1002
+ return (
1003
+ <div>
1004
+ {loading && 'Loading...'}
1005
+
1006
+ {!loading && (
1007
+ <>
1008
+ <h1>
1009
+ {productManifest.name} : {slogan}
1010
+ </h1>
1011
+
1012
+ <h2>{label}</h2>
1013
+
1014
+ <UCPanel
1015
+ clearAfterExec={false}
1016
+ onDone={async (ucor) => append0(ucor)}
1017
+ onError={onError}
1018
+ renderAutoExecLoader={UCAutoExecLoader}
1019
+ renderExecTouchable={UCExecTouchable}
1020
+ renderForm={UCForm}
1021
+ sleepInMs={200} // Fake delay to see submit wording changing
1022
+ uc={buyAssetUC}
1023
+ />
1024
+
1025
+ <table>
1026
+ <thead>
1027
+ <tr>
1028
+ <th>{idLabel}</th>
1029
+ <th>{executedDirectlyLabel}</th>
1030
+ </tr>
1031
+ </thead>
1032
+ <tbody>
1033
+ {buyAssetPart0?.items.map((i) => (
1034
+ <tr key={i.id}>
1035
+ <td>{i.id}</td>
1036
+ <td>{i.executedDirectly ? '✅' : '❌'}</td>
1037
+ </tr>
1038
+ ))}
1039
+ </tbody>
1040
+ <tfoot>
1041
+ <tr>
1042
+ <th>{i18nManager.t('total')}</th>
1043
+ <th>{buyAssetPart0?.pagination.total}</th>
1044
+ </tr>
1045
+ </tfoot>
1046
+ </table>
1047
+ </>
1048
+ )}
1049
+ </div>
1050
+ );
1051
+ }
1052
+ ```
1053
+
1054
+ #### index.tsx
1055
+
1056
+ ```typescript
1057
+ import { DIContextProvider } from 'libmodulor/react';
1058
+ import React, { StrictMode } from 'react';
1059
+ import ReactDOM from 'react-dom/client';
1060
+
1061
+ import App from './components/App.js';
1062
+ import container from './container.js';
1063
+
1064
+ const rootElt = document.getElementById('root');
1065
+ if (!rootElt) {
1066
+ throw new Error('Add a div#root in index.html');
1067
+ }
1068
+
1069
+ ReactDOM.createRoot(rootElt).render(
1070
+ <StrictMode>
1071
+ <DIContextProvider container={container}>
1072
+ <App />
1073
+ </DIContextProvider>
1074
+ </StrictMode>,
1075
+ );
1076
+ ```
1077
+
1078
+ #### Build & Run
1079
+
1080
+ Update `package.json` to add the `web` build to the `build` command.
1081
+
1082
+ ```json
1083
+ "build": "tsc --project tsconfig.build.json && cp .env dist/products/SuperTrader/server/.env && vite -c src/products/SuperTrader/vite.config.web.ts build",
1084
+ ```
1085
+
1086
+ Update `src/products/SuperTrader/server/container.ts` to mount the `public` directory.
1087
+
1088
+ ```diff
1089
+ ...TARGET_DEFAULT_SERVER_MANAGER_SETTINGS,
1090
+ +server_static_dir_path: 'public',
1091
+ ```
1092
+
1093
+ Press <kbd>ctrl</kbd> + <kbd>C</kbd> to stop the server (we'll setup hot reload later).
1094
+
1095
+ ```sh
1096
+ yarn build && yarn run:server
1097
+ open http://localhost:7443
1098
+ ```
1099
+
1100
+ Et voilà ! The server is running ! Fill the form and see how it automatically submits to the server with client side and server side validation out of the box.
1101
+
1102
+ <img src="/docs/assets/trading-target-web.png" width="600px">
1103
+
1104
+ ```sh
1105
+ yarn lint && git add . && git commit -m "feat: add the web target"
1106
+ ```
1107
+
1108
+ ### Switch to a persistent data storage
1109
+
1110
+ By default, the data is stored in memory on the server. Therefore, whenever we restart it, we lose everything. That is not very practical in real life scenarios. Let's use SQLite instead.
1111
+
1112
+ ```sh
1113
+ yarn add "knex@^3.1.0" "sqlite3@^5.1.7"
1114
+ ```
1115
+
1116
+ Update `src/products/SuperTrader/server/container.ts` to change the implementation.
1117
+
1118
+ ```diff
1119
+ [...]
1120
+ TARGET_DEFAULT_SERVER_MANAGER_SETTINGS,
1121
+ + type UCDataStore,
1122
+ [...]
1123
+ +import { KnexUCDataStore } from 'libmodulor/uc-data-store/knex';
1124
+ [...]
1125
+ +type S = KnexUCDataStoreSettings & ServerManagerSettings;
1126
+ [...]
1127
+ ...TARGET_DEFAULT_SERVER_MANAGER_SETTINGS,
1128
+ + knex_uc_data_store_conn_string: 'postgresql://toto',
1129
+ + knex_uc_data_store_file_path: 'uc-data-store.sqlite',
1130
+ + knex_uc_data_store_pool_max: 5,
1131
+ + knex_uc_data_store_pool_min: 0,
1132
+ + knex_uc_data_store_type: 'sqlite3',
1133
+ server_static_dir_path: 'public',
1134
+ [...]
1135
+ container.rebind<SettingsManager>('SettingsManager').to(EnvSettingsManager);
1136
+ +container.rebind<UCDataStore>('UCDataStore').to(KnexUCDataStore);
1137
+ ```
1138
+
1139
+ Press <kbd>ctrl</kbd> + <kbd>C</kbd> to stop the server.
1140
+
1141
+ ```sh
1142
+ yarn build && yarn run:server
1143
+ ```
1144
+
1145
+ Fill and submit the use case multiple times.
1146
+
1147
+ Open the SQLite database with you with your favorite DB editor (e.g. TablePlus, DBeaver...).
1148
+
1149
+ ```sh
1150
+ open dist/products/SuperTrader/server/uc-data-store.sqlite
1151
+ ```
1152
+
1153
+ You should see all your submissions stored in the database.
1154
+
1155
+ ```sh
1156
+ yarn lint && git add . && git commit -m "feat: persist data in SQLite"
1157
+ ```
1158
+
1159
+ ### Define wording for humans
1160
+
1161
+ By default, the UI simply "humanizes" the use case field keys. It's fine for technical people, but not for humans.
1162
+
1163
+ Update `src/apps/Trading/src/i18n.ts` and add the following keys to `en`.
1164
+
1165
+ ```typescript
1166
+ uc_BuyAsset_label: 'Buy an asset',
1167
+ uc_BuyAsset_i_submit_idle: 'Send buy order',
1168
+ uc_BuyAsset_i_submit_submitting: 'Sending',
1169
+ ucif_isin_label: 'ISIN',
1170
+ ucif_qty_label: 'Quantity',
1171
+ ucof_executedDirectly_label: '🚀 Executed directly',
1172
+ ucof_id_label: 'Identifier',
1173
+ validation_format_ISIN:
1174
+ 'Must be 2 uppercase letters, followed by 9 alphanumeric characters and 1 digit',
1175
+ ```
1176
+
1177
+ Update `src/products/SuperTrader/i18n.ts` and add the following keys to `en`.
1178
+
1179
+ ```typescript
1180
+ total: 'Total',
1181
+ ```
1182
+
1183
+ > [!NOTE]
1184
+ > We distinguish what's related to the app from what's related to the product. Usually, in the app's `i18n`, you'll add only translations following a certain convention like `dt_*` (data type choices), `err_*` (error messages), `uc_*` (use cases), `ucif_*` (use case input fields), `ucof_*` (use case output fields), `validation_*` (validation messages), etc.
1185
+
1186
+ Press <kbd>ctrl</kbd> + <kbd>C</kbd> to stop the server.
1187
+
1188
+ ```sh
1189
+ yarn build && yarn run:server
1190
+ ```
1191
+
1192
+ Refresh the page. You should see a better wording. Try to type an invalid `ISIN` and see how the full validation message is displayed as well.
1193
+
1194
+ <img src="/docs/assets/trading-target-web-human.png" width="600px">
1195
+
1196
+ ```sh
1197
+ yarn lint && git add . && git commit -m "feat: define wording for humans"
1198
+ ```
1199
+
1200
+ ### Create the cli Target
1201
+
1202
+ We'll use the pre-built [Node.js parseArgs](https://nodejs.org/api/util.html#utilparseargsconfig) CLI program.
1203
+
1204
+ ```sh
1205
+ mkdir src/products/SuperTrader/cli
1206
+ touch src/products/SuperTrader/cli/{container.ts,index.ts}
1207
+ ```
1208
+
1209
+ #### container.ts
1210
+
1211
+ ```typescript
1212
+ import {
1213
+ CONTAINER_OPTS,
1214
+ type ServerClientManagerSettings,
1215
+ TARGET_DEFAULT_SERVER_CLIENT_MANAGER_SETTINGS,
1216
+ bindCommon,
1217
+ bindProduct,
1218
+ } from 'libmodulor';
1219
+ import { bindNodeCLI, bindNodeCore } from 'libmodulor/node';
1220
+ import { Container } from 'inversify';
1221
+
1222
+ import { I18n } from '../i18n.js';
1223
+ import { Manifest } from '../manifest.js';
1224
+
1225
+ const container = new Container(CONTAINER_OPTS);
1226
+
1227
+ bindCommon<ServerClientManagerSettings>(container, () => ({
1228
+ ...TARGET_DEFAULT_SERVER_CLIENT_MANAGER_SETTINGS,
1229
+ }));
1230
+ bindNodeCore(container);
1231
+ bindNodeCLI(container);
1232
+ bindProduct(container, Manifest, I18n);
1233
+
1234
+ export default container;
1235
+ ```
1236
+
1237
+ #### index.ts
1238
+
1239
+ ```typescript
1240
+ import {
1241
+ APPS_ROOT_DIR_NAME,
1242
+ type FSManager,
1243
+ type I18nManager,
1244
+ } from 'libmodulor';
1245
+ import { NodeCoreCLIManager } from 'libmodulor/node';
1246
+
1247
+ import container from './container.js';
1248
+
1249
+ await container.get<I18nManager>('I18nManager').init();
1250
+
1251
+ await container.resolve(NodeCoreCLIManager).handleCommand({
1252
+ appsRootPath: container
1253
+ .get<FSManager>('FSManager')
1254
+ .path('..', '..', '..', APPS_ROOT_DIR_NAME),
1255
+ srcImporter: (path) => import(path),
1256
+ });
1257
+ ```
1258
+
1259
+ #### Build & Run
1260
+
1261
+ Update `package.json` to add a new entry to the `scripts`.
1262
+
1263
+ ```json
1264
+ "run:cli": "cd dist/products/SuperTrader/cli && node index.js",
1265
+ ```
1266
+
1267
+ ```sh
1268
+ yarn build && yarn run:cli
1269
+ ```
1270
+
1271
+ You can see the CLI help appearing with the available commands.
1272
+
1273
+ > [!TIP]
1274
+ > Update the app's `i18n.ts` to add `uc_BuyAsset_desc` and `ucif_isin_desc` to have a more detailed help section.
1275
+
1276
+ Start the server if it's not running.
1277
+
1278
+ ```sh
1279
+ yarn run:server
1280
+ ```
1281
+
1282
+ Execute the CLI in another terminal or tab.
1283
+
1284
+ ```sh
1285
+ yarn run:cli BuyAsset
1286
+ # ❌ ISIN must be filled
1287
+ yarn run:cli BuyAsset --isin US02079K3059 --limit 123.5 --qty 150
1288
+ # ✅ {"parts":{"_0":{"items":[{"executedDirectly":false,"id":"da3dc295-6d7c-41b1-a00a-62683f3e6ab9"}],"total":1}}}
1289
+ ```
1290
+
1291
+ Open the SQLite database with you with your favorite DB editor (e.g. TablePlus, DBeaver...).
1292
+
1293
+ ```sh
1294
+ open dist/products/SuperTrader/server/uc-data-store.sqlite
1295
+ ```
1296
+
1297
+ ```sh
1298
+ yarn lint && git add . && git commit -m "feat: add the cli target"
1299
+ ```
1300
+
1301
+ ### Create the mcp-server Target
1302
+
1303
+ We'll use the pre-built local [stdio transport](https://modelcontextprotocol.io/docs/concepts/transports#standard-input-output-stdio) server.
1304
+
1305
+ ```sh
1306
+ yarn add "@modelcontextprotocol/sdk@^1.0.4"
1307
+ ```
1308
+
1309
+ ```sh
1310
+ mkdir src/products/SuperTrader/mcp-server
1311
+ touch src/products/SuperTrader/mcp-server/{container.ts,index.ts}
1312
+ ```
1313
+
1314
+ #### container.ts
1315
+
1316
+ ```typescript
1317
+ import {
1318
+ CONTAINER_OPTS,
1319
+ type ServerClientManagerSettings,
1320
+ type ServerManager,
1321
+ TARGET_DEFAULT_SERVER_CLIENT_MANAGER_SETTINGS,
1322
+ bindCommon,
1323
+ bindProduct,
1324
+ } from 'libmodulor';
1325
+ import { bindNodeCore, bindServer } from 'libmodulor/node';
1326
+ import { NodeLocalStdioMCPServerManager } from 'libmodulor/node-mcp';
1327
+ import { Container } from 'inversify';
1328
+
1329
+ import { I18n } from '../i18n.js';
1330
+ import { Manifest } from '../manifest.js';
1331
+
1332
+ type S = ServerClientManagerSettings;
1333
+
1334
+ const container = new Container(CONTAINER_OPTS);
1335
+
1336
+ bindCommon<S>(container, () => ({
1337
+ ...TARGET_DEFAULT_SERVER_CLIENT_MANAGER_SETTINGS,
1338
+ logger_level: 'error',
1339
+ }));
1340
+ bindNodeCore(container);
1341
+ bindServer(container);
1342
+ bindProduct(container, Manifest, I18n);
1343
+
1344
+ container
1345
+ .bind<ServerManager>('ServerManager')
1346
+ .to(NodeLocalStdioMCPServerManager);
1347
+
1348
+ export default container;
1349
+ ```
1350
+
1351
+ #### index.ts
1352
+
1353
+ ```typescript
1354
+ import {
1355
+ APPS_ROOT_DIR_NAME,
1356
+ type FSManager,
1357
+ type I18nManager,
1358
+ } from 'libmodulor';
1359
+ import { MCPServerBooter } from 'libmodulor/node-mcp';
1360
+
1361
+ import container from './container.js';
1362
+
1363
+ await container.get<I18nManager>('I18nManager').init();
1364
+
1365
+ await container.resolve(MCPServerBooter).exec({
1366
+ appsRootPath: container
1367
+ .get<FSManager>('FSManager')
1368
+ .path('..', '..', '..', APPS_ROOT_DIR_NAME),
1369
+ srcImporter: (path) => import(path),
1370
+ });
1371
+ ```
1372
+
1373
+ > [!NOTE]
1374
+ > Note how we increase the level of logs to `error` because logging on stdout [messes with the stdio transport](https://modelcontextprotocol.io/docs/tools/debugging#server-side-logging).
1375
+
1376
+ #### Claude Desktop
1377
+
1378
+ If you don't have [Claude Desktop](https://claude.ai/download) on your machine, install it.
1379
+
1380
+ The following instructions are for macOS. You might need to adapt the paths if you are using another OS.
1381
+
1382
+ Register the mcp server in Claude (make sure you adapt the absolute path to your `libmodulor-tuto` directory).
1383
+
1384
+ ```sh
1385
+ nano ~/Library/Application\ Support/Claude/claude_desktop_config.json
1386
+ ```
1387
+
1388
+ ```json
1389
+ {
1390
+ "mcpServers": {
1391
+ "libmodulor-tuto": {
1392
+ "command": "node",
1393
+ "args": [
1394
+ "/Users/toto/libmodulor-tuto/dist/products/SuperTrader/mcp-server/index.js"
1395
+ ]
1396
+ }
1397
+ }
1398
+ }
1399
+ ```
1400
+
1401
+ If you want to enable debugging within Claude Desktop :
1402
+
1403
+ ```sh
1404
+ nano ~/Library/Application\ Support/Claude/developer_settings.json
1405
+ ```
1406
+
1407
+ ```json
1408
+ {
1409
+ "allowDevTools": true
1410
+ }
1411
+ ```
1412
+
1413
+ To vizualize the logs :
1414
+
1415
+ ```sh
1416
+ ls -la ~/Library/Logs/Claude
1417
+ tail -f ~/Library/Logs/Claude/mcp.log
1418
+ tail -f ~/Library/Logs/Claude/mcp-server-libmodulor-tuto.log
1419
+ ```
1420
+
1421
+ To open the Chrome Developer Tools wihtin Claude, press <kbd>cmd</kbd> + <kbd>option</kbd> + <kbd>shift</kbd> + <kbd>I</kbd> (should be easy if you're an emacs user).
1422
+
1423
+ #### Build & Run
1424
+
1425
+ Press <kbd>ctrl</kbd> + <kbd>C</kbd> to stop the server.
1426
+
1427
+ ```sh
1428
+ yarn build && yarn run:server
1429
+ ```
1430
+
1431
+ Launch Claude Desktop.
1432
+
1433
+ At the bottom right of the prompt you should see a little hammer 🔨 indicating `1 MCP Tool available`.
1434
+
1435
+ Click on it. You should see the `BuyAsset` use case registered.
1436
+
1437
+ Now just write a prompt like below :
1438
+
1439
+ ```txt
1440
+ Dear Claude. Please buy 150 shares of Google.
1441
+ ```
1442
+
1443
+ And let the magic happens.
1444
+
1445
+ <img src="/docs/assets/trading-target-mcp-server.png" width="600px">
1446
+
1447
+ Open the SQLite database with you with your favorite DB editor (e.g. TablePlus, DBeaver...).
1448
+
1449
+ ```sh
1450
+ open dist/products/SuperTrader/server/uc-data-store.sqlite
1451
+ ```
1452
+
1453
+ ```sh
1454
+ yarn lint && git add . && git commit -m "feat: add the mcp-server target"
1455
+ ```
1456
+
1457
+ ### Summary
1458
+
1459
+ ```sh
1460
+ git log --oneline
1461
+ ```
1462
+
1463
+ ```sh
1464
+ 7be4e06 (HEAD -> master) feat: add the mcp-server target
1465
+ 0fb3d57 feat: add the cli target
1466
+ e8a52a6 feat: define wording for humans
1467
+ 3d41f82 feat: persist data in SQLite
1468
+ d55bc42 feat: add the web target
1469
+ 1af90ea feat: add the server target
1470
+ dffa202 feat: add the product
1471
+ 8b83e1f test: init app tests
1472
+ 0f99382 feat: add the use case
1473
+ 33ac247 feat: add the app
1474
+ 09909aa chore: init source code
1475
+ ```
1476
+
1477
+ That was cool ! In a couple of minutes we have created the foundations of a multi-platform application.
1478
+
1479
+ And we have only touched the surface as `libmodulor` has much more to offer.
1480
+
1481
+ We have used the "pre-built" targets, but it's totally possible to do the same with a Hono server, a stricli CLI, a Vue.js SPA and so on. All the targets implement generic interfaces provided by `libmodulor`, making the whole thing fully modular.
1482
+
1483
+ Coming soon : the Advanced Guide.
1484
+
1485
+ If you appreciated this Guide or have any feedback, of all kinds, please feel free to send me a message. I'll be happy to discuss and help.