lanka 1.0.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/LICENSE +21 -0
- package/README.md +242 -0
- package/dist/ALankaGateway-ExlRGT3D.d.ts +122 -0
- package/dist/ALankaSharedStore-B7uepuuk.d.ts +29 -0
- package/dist/ILankaApiError-zI_2bNs0.d.ts +16 -0
- package/dist/ILankaRuntimeConfig-Vl436GWK.d.ts +62 -0
- package/dist/ILankaScenarioMetadata-Bu-yggTZ.d.ts +14 -0
- package/dist/ILankaScenarioVM-DuCyPoyT.d.ts +260 -0
- package/dist/LankaError-B1HtuIkw.d.ts +86 -0
- package/dist/LankaScenarioLocator-BGQHwf3n.d.ts +144 -0
- package/dist/LankaSharedStoreLocator-MvCpav5F.d.ts +126 -0
- package/dist/TLankaErrorHandler-Yfqtdh1M.d.ts +3 -0
- package/dist/_extend/index.d.ts +34 -0
- package/dist/_extend/index.js +36 -0
- package/dist/_extend/index.js.map +1 -0
- package/dist/_internal/index.d.ts +43 -0
- package/dist/_internal/index.js +34 -0
- package/dist/_internal/index.js.map +1 -0
- package/dist/activeRuntime-FcsSJvUg.d.ts +84 -0
- package/dist/bootstrap/index.d.ts +242 -0
- package/dist/bootstrap/index.js +22 -0
- package/dist/bootstrap/index.js.map +1 -0
- package/dist/chunk-73IVH3C6.js +52 -0
- package/dist/chunk-73IVH3C6.js.map +1 -0
- package/dist/chunk-BGVDPDX4.js +29 -0
- package/dist/chunk-BGVDPDX4.js.map +1 -0
- package/dist/chunk-C2HP7CRD.js +224 -0
- package/dist/chunk-C2HP7CRD.js.map +1 -0
- package/dist/chunk-D27MREPB.js +13 -0
- package/dist/chunk-D27MREPB.js.map +1 -0
- package/dist/chunk-DTO27QFR.js +59 -0
- package/dist/chunk-DTO27QFR.js.map +1 -0
- package/dist/chunk-EWVDJYCC.js +296 -0
- package/dist/chunk-EWVDJYCC.js.map +1 -0
- package/dist/chunk-FIR4XTBL.js +136 -0
- package/dist/chunk-FIR4XTBL.js.map +1 -0
- package/dist/chunk-ILQNYQY5.js +200 -0
- package/dist/chunk-ILQNYQY5.js.map +1 -0
- package/dist/chunk-J5LA3Q6D.js +14 -0
- package/dist/chunk-J5LA3Q6D.js.map +1 -0
- package/dist/chunk-MDZ42XRH.js +10 -0
- package/dist/chunk-MDZ42XRH.js.map +1 -0
- package/dist/chunk-Q3SOVBIJ.js +176 -0
- package/dist/chunk-Q3SOVBIJ.js.map +1 -0
- package/dist/chunk-Q7QESSYF.js +49 -0
- package/dist/chunk-Q7QESSYF.js.map +1 -0
- package/dist/chunk-RKYKK6MN.js +19 -0
- package/dist/chunk-RKYKK6MN.js.map +1 -0
- package/dist/chunk-RYFZCAQ3.js +637 -0
- package/dist/chunk-RYFZCAQ3.js.map +1 -0
- package/dist/chunk-UJEC7H6K.js +39 -0
- package/dist/chunk-UJEC7H6K.js.map +1 -0
- package/dist/chunk-XESL274R.js +23 -0
- package/dist/chunk-XESL274R.js.map +1 -0
- package/dist/chunk-YR4MZXMU.js +13 -0
- package/dist/chunk-YR4MZXMU.js.map +1 -0
- package/dist/config/index.d.ts +63 -0
- package/dist/config/index.js +16 -0
- package/dist/config/index.js.map +1 -0
- package/dist/createLankaScope-Bc_vChRs.d.ts +30 -0
- package/dist/errors/index.d.ts +37 -0
- package/dist/errors/index.js +16 -0
- package/dist/errors/index.js.map +1 -0
- package/dist/gateway/index.d.ts +258 -0
- package/dist/gateway/index.js +438 -0
- package/dist/gateway/index.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +43 -0
- package/dist/index.js.map +1 -0
- package/dist/lankaHttpInFlight-Bk1eIuSx.d.ts +43 -0
- package/dist/lankaRequestMiddleware-DAC5kCb7.d.ts +62 -0
- package/dist/lankaStandardValidator-CL-r-zEV.d.ts +34 -0
- package/dist/locator/index.d.ts +282 -0
- package/dist/locator/index.js +67 -0
- package/dist/locator/index.js.map +1 -0
- package/dist/logger/index.d.ts +146 -0
- package/dist/logger/index.js +11 -0
- package/dist/logger/index.js.map +1 -0
- package/dist/mock/index.d.ts +16 -0
- package/dist/mock/index.js +58 -0
- package/dist/mock/index.js.map +1 -0
- package/dist/role/index.d.ts +48 -0
- package/dist/role/index.js +7 -0
- package/dist/role/index.js.map +1 -0
- package/dist/scenario/index.d.ts +204 -0
- package/dist/scenario/index.js +28 -0
- package/dist/scenario/index.js.map +1 -0
- package/dist/validation/index.d.ts +18 -0
- package/dist/validation/index.js +53 -0
- package/dist/validation/index.js.map +1 -0
- package/dist/viewmodel/index.d.ts +478 -0
- package/dist/viewmodel/index.js +701 -0
- package/dist/viewmodel/index.js.map +1 -0
- package/package.json +99 -0
- package/skills/lanka-core/SKILL.md +241 -0
- package/skills/lanka-core/reference.md +1090 -0
- package/skills/lanka-packages/SKILL.md +70 -0
|
@@ -0,0 +1,1090 @@
|
|
|
1
|
+
<!-- Generated from core/GUIDE.md by scripts/skills.mjs. Edit the guide. -->
|
|
2
|
+
|
|
3
|
+
> **`lanka@1.0.0`** — this document describes that version.
|
|
4
|
+
>
|
|
5
|
+
> Install: `npm install lanka react zustand` (the peers are not optional; only npm adds a missing one for you).
|
|
6
|
+
>
|
|
7
|
+
> Complete code, compiled and run in CI: [core/_playground/playground.test.tsx](https://github.com/lankajs/lanka/blob/main/core/_playground/playground.test.tsx)
|
|
8
|
+
|
|
9
|
+
# lanka — user guide
|
|
10
|
+
|
|
11
|
+
The core package. Everything in this guide works with `lanka` alone; modules and
|
|
12
|
+
plugins are optional and have guides of their own.
|
|
13
|
+
|
|
14
|
+
If you want to know _why_ the framework is shaped this way, read
|
|
15
|
+
[README.md](https://github.com/lankajs/lanka/blob/main/core/README.md). This file is about _using_ it.
|
|
16
|
+
|
|
17
|
+
## You will learn
|
|
18
|
+
|
|
19
|
+
- how to start the framework in one line, and what that line does
|
|
20
|
+
- the four layers, and the one direction imports go
|
|
21
|
+
- how a screen gets data: gateway → ViewModel → view
|
|
22
|
+
- how two screens reach each other without importing each other
|
|
23
|
+
- what fails loudly on purpose, and why
|
|
24
|
+
|
|
25
|
+
> [!NOTE]
|
|
26
|
+
> This guide shows how lanka is _meant_ to be used. Very little of it is
|
|
27
|
+
> required: [ARCHITECTURE.md](https://github.com/lankajs/lanka/blob/main/ARCHITECTURE.md) separates the five things the
|
|
28
|
+
> machine checks from the many that are advice you can take or leave.
|
|
29
|
+
|
|
30
|
+
## Contents
|
|
31
|
+
|
|
32
|
+
- [Install](#install)
|
|
33
|
+
- [Your first application](#your-first-application)
|
|
34
|
+
- [The layers, and which way imports go](#the-layers-and-which-way-imports-go)
|
|
35
|
+
- [The instance](#the-instance)
|
|
36
|
+
- [The host contract](#the-host-contract)
|
|
37
|
+
- [Flags](#flags)
|
|
38
|
+
- [Gateways — talking to a server](#gateways--talking-to-a-server)
|
|
39
|
+
- [Failure](#failure)
|
|
40
|
+
- [Validating a response](#validating-a-response)
|
|
41
|
+
- [ViewModels](#viewmodels)
|
|
42
|
+
- [Choosing a coordination tool](#choosing-a-coordination-tool)
|
|
43
|
+
- [Shared stores](#shared-stores)
|
|
44
|
+
- [Stateless ViewModels](#stateless-viewmodels)
|
|
45
|
+
- [Lazy ViewModels](#lazy-viewmodels)
|
|
46
|
+
- [Scenarios — cross-screen facts](#scenarios--cross-screen-facts)
|
|
47
|
+
- [The locator](#the-locator)
|
|
48
|
+
- [Scopes](#scopes)
|
|
49
|
+
- [Mock mode](#mock-mode)
|
|
50
|
+
- [The logger](#the-logger)
|
|
51
|
+
- [Plugins](#plugins)
|
|
52
|
+
- [Roles — both styles for your own layer](#roles--both-styles-for-your-own-layer)
|
|
53
|
+
- [Import map](#import-map)
|
|
54
|
+
- [What else ships](#what-else-ships)
|
|
55
|
+
- [Cases](#cases)
|
|
56
|
+
- [Common mistakes](#common-mistakes)
|
|
57
|
+
- [Recap](#recap)
|
|
58
|
+
|
|
59
|
+
## Install
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
npm install lanka react react-dom zustand
|
|
63
|
+
pnpm add lanka react react-dom zustand
|
|
64
|
+
yarn add lanka react react-dom zustand
|
|
65
|
+
bun add lanka react react-dom zustand
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
**The peers are listed on purpose.** `react` 19 and `zustand` 5 are peer
|
|
69
|
+
dependencies, and only npm installs those for you. Under pnpm, yarn or bun a
|
|
70
|
+
missing peer is a warning at install time and a resolution error at build time —
|
|
71
|
+
name them once and the question never comes up.
|
|
72
|
+
|
|
73
|
+
> [!NOTE]
|
|
74
|
+
> Any package manager works, and nothing in the framework knows which one you
|
|
75
|
+
> used. The one place they differ is the line above; the second is how you run a
|
|
76
|
+
> package's binary, which [`@lankajs/tool-skills`](https://github.com/lankajs/lanka/blob/main/tools/skills/GUIDE.md#install)
|
|
77
|
+
> spells out for each.
|
|
78
|
+
|
|
79
|
+
TypeScript is not required, but every API is written for it and the types are the
|
|
80
|
+
documentation of last resort.
|
|
81
|
+
|
|
82
|
+
## Your first application
|
|
83
|
+
|
|
84
|
+
One line:
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
import { startLanka } from "lanka";
|
|
88
|
+
|
|
89
|
+
const lanka = await startLanka({ apiBaseUrl: "https://api.example.com" });
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
That creates the instance, activates it, installs any plugins and awaits
|
|
93
|
+
bootstrap.
|
|
94
|
+
|
|
95
|
+
**Every field is optional, `apiBaseUrl` included.** `await startLanka()` is a
|
|
96
|
+
working application: with no base URL a gateway's paths are used as written,
|
|
97
|
+
which is what you want when the app is served from its API's origin, or when it
|
|
98
|
+
talks to several APIs and each gateway names its own. What the framework will
|
|
99
|
+
never do is guess a default like `/api` — that would be silently wrong for
|
|
100
|
+
everyone who does not use it, and the symptom is a 404 three layers from the
|
|
101
|
+
cause.
|
|
102
|
+
|
|
103
|
+
Everything else is a field on the same call:
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
const lanka = await startLanka({
|
|
107
|
+
apiBaseUrl: import.meta.env.VITE_API_URL,
|
|
108
|
+
messages: { networkErrorMessage: () => "No connection" },
|
|
109
|
+
flags: { isDevelopment: import.meta.env.DEV },
|
|
110
|
+
plugins: [lankaHttp(lankaTokenSessionPolicy({ auth }))],
|
|
111
|
+
services: [{ name: "session", init: () => restoreSession() }],
|
|
112
|
+
});
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Pass `host` instead of `apiBaseUrl` when the copy is yours from the start —
|
|
116
|
+
`ILankaHost` requires all four members, so a missing translator is a compile
|
|
117
|
+
error rather than an untranslated string in somebody's interface.
|
|
118
|
+
|
|
119
|
+
### Starting inside Next, React Router or Expo
|
|
120
|
+
|
|
121
|
+
`startLanka` starts ONE instance, which is exactly right in a browser and wrong
|
|
122
|
+
on a server: a process there serves many users, and one instance would be shared
|
|
123
|
+
between them. If your host framework renders on a server, the instance is created
|
|
124
|
+
per request instead — `runLankaRequest` and `runLankaStatic` in
|
|
125
|
+
[@lankajs/host](https://github.com/lankajs/lanka/blob/main/modules/host/GUIDE.md) — and this call stays for the browser
|
|
126
|
+
half. The two do not conflict; they answer for different sides of the network.
|
|
127
|
+
|
|
128
|
+
### When one line is not enough
|
|
129
|
+
|
|
130
|
+
`startLanka` is `createLanka` and `bootstrap` in the order that works. Write
|
|
131
|
+
them out when something has to happen between the two — registering a singleton
|
|
132
|
+
whose construction reads a service's result, say:
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
const lanka = createLanka({ host, flags }); // already active from here
|
|
136
|
+
lanka.locators.singletons.register("SessionService", SessionService);
|
|
137
|
+
await lanka.bootstrap({ services });
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Two things are worth knowing whichever way you start:
|
|
141
|
+
|
|
142
|
+
1. **`createLanka()` activates the instance itself.** `lanka.activate()` exists
|
|
143
|
+
for the case with two instances in one process — a test beside the app — where
|
|
144
|
+
you say which one is ambient. A single-instance application never calls it.
|
|
145
|
+
2. **`bootstrap()` brings up the scenario layer.** You do not call
|
|
146
|
+
`lankaScenarioBootstrap` yourself: bootstrap does it, after the async services
|
|
147
|
+
by default and in the ordered phase under `scenarios: { sync: true }`.
|
|
148
|
+
|
|
149
|
+
What must be in order: register a name before constructing whatever resolves it,
|
|
150
|
+
and let ViewModels with `scenarioHandlers` exist before bootstrap runs — it binds
|
|
151
|
+
what exists when it runs.
|
|
152
|
+
|
|
153
|
+
In React, do this once at the module top level of your entry file, or in an
|
|
154
|
+
effect in a root component that also calls `lanka.dispose()` on unmount.
|
|
155
|
+
|
|
156
|
+
## The layers, and which way imports go
|
|
157
|
+
|
|
158
|
+
```
|
|
159
|
+
View (React) renders. Reads one hook and nothing else.
|
|
160
|
+
│
|
|
161
|
+
▼
|
|
162
|
+
ViewModel owns state and actions. Calls gateways, triggers scenarios.
|
|
163
|
+
│
|
|
164
|
+
▼
|
|
165
|
+
Gateway states endpoints. No state, no error policy.
|
|
166
|
+
│
|
|
167
|
+
▼
|
|
168
|
+
Request / Transport what a response IS, and how bytes travel.
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
**Imports go one way.** A ViewModel may reach a gateway; a gateway does not know
|
|
172
|
+
ViewModels exist. This is not an agreement — [`@lankajs/tool-eslint`](https://github.com/lankajs/lanka/blob/main/tools/eslint/GUIDE.md)
|
|
173
|
+
names the file and the line.
|
|
174
|
+
|
|
175
|
+
| Layer | May use | Must never |
|
|
176
|
+
| ------------------- | --------------------------------------------- | -------------------------------------------- |
|
|
177
|
+
| View | its ViewModel's hook | import a gateway; own loading or retry state |
|
|
178
|
+
| ViewModel | gateways, services, scenarios, a shared store | import another ViewModel |
|
|
179
|
+
| Gateway | its request, a validator, a mock handler | import another gateway; hold state |
|
|
180
|
+
| Request / Transport | `fetch`, or whatever you supply | know an endpoint or a domain type |
|
|
181
|
+
|
|
182
|
+
Two things sit **across** the layers rather than inside one:
|
|
183
|
+
|
|
184
|
+
- **Scenarios** are how two ViewModels reach each other without importing
|
|
185
|
+
anything. One triggers a fact; the others subscribe.
|
|
186
|
+
- **The locator** holds singletons, gateways, shared stores and scenarios by
|
|
187
|
+
name, so a screen asks for a name and never learns where the object came from.
|
|
188
|
+
|
|
189
|
+
And one thing points **inward**, from your application into the framework: the
|
|
190
|
+
`.lanka_di/` barrels. That is the single permitted inversion, and it holds
|
|
191
|
+
because there is exactly one reading side — the framework. Your own code
|
|
192
|
+
resolves through the locator instead.
|
|
193
|
+
|
|
194
|
+
### Deciding where a new file goes
|
|
195
|
+
|
|
196
|
+
Three questions, and the answer to all three is usually forced:
|
|
197
|
+
|
|
198
|
+
**① What kind of work does it do?**
|
|
199
|
+
|
|
200
|
+
| The work | Where it lives |
|
|
201
|
+
| -------------------------------------- | -------------- |
|
|
202
|
+
| talks to a server, a storage, an SDK | a gateway |
|
|
203
|
+
| remembers something, decides something | a ViewModel |
|
|
204
|
+
| draws the screen | a component |
|
|
205
|
+
| pure input → output, no memory | a plain helper |
|
|
206
|
+
| announces "something happened" | a scenario |
|
|
207
|
+
|
|
208
|
+
One file doing two of these is two files in a trench coat.
|
|
209
|
+
|
|
210
|
+
**② Who may use it?** Pick the smallest scope that fits today: one component →
|
|
211
|
+
one screen → one feature → the whole app.
|
|
212
|
+
|
|
213
|
+
**③ Which way does it point?** Downward, toward the more general. A screen may
|
|
214
|
+
use a helper; a helper may never use a screen.
|
|
215
|
+
|
|
216
|
+
## The instance
|
|
217
|
+
|
|
218
|
+
`createLanka()` returns an `ILankaInstance`, and it owns **all** framework state:
|
|
219
|
+
the event bus, the scenario registries, four locator caches, the config and the
|
|
220
|
+
in-flight request counter.
|
|
221
|
+
|
|
222
|
+
| Method | What it does |
|
|
223
|
+
| -------------------------- | ------------------------------------------------------ |
|
|
224
|
+
| `activate()` | Makes this instance the one ambient facades resolve |
|
|
225
|
+
| `bootstrap(config?)` | Runs services and the scenario layer. Idempotent |
|
|
226
|
+
| `isBootstrapped()` | Has it run yet |
|
|
227
|
+
| `resolve<T>(name)` | Resolves a service in the root scope |
|
|
228
|
+
| `createScope()` | A lifetime shorter than the application's |
|
|
229
|
+
| `use(plugin)` | Installs a plugin; returns a remover |
|
|
230
|
+
| `useRequestMiddleware(mw)` | Wraps every request; returns a remover |
|
|
231
|
+
| `setRequestTimeout(ms)` | Default timeout for this instance's requests |
|
|
232
|
+
| `dispose()` | Unsubscribes, clears registries and the ambient point |
|
|
233
|
+
| `locators` | `.singletons` `.gateways` `.scenarios` `.sharedStores` |
|
|
234
|
+
|
|
235
|
+
Because state lives on the instance and not in module variables, two applications
|
|
236
|
+
can run in one process — an app beside Storybook, a test beside another test —
|
|
237
|
+
without sharing a bus or a cache.
|
|
238
|
+
|
|
239
|
+
### Bootstrap services
|
|
240
|
+
|
|
241
|
+
```ts
|
|
242
|
+
await lanka.bootstrap({
|
|
243
|
+
services: [
|
|
244
|
+
{ name: "config", init: loadConfig, sync: true, priority: 10 },
|
|
245
|
+
{ name: "analytics", init: startAnalytics, optional: true, timeoutMs: 2000 },
|
|
246
|
+
],
|
|
247
|
+
});
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
- `sync: true` runs the service in the ordered phase; the default phase is
|
|
251
|
+
parallel.
|
|
252
|
+
- `priority` orders the sync phase, higher first.
|
|
253
|
+
- `optional: true` means a failure does not abort bootstrap. Without it, one
|
|
254
|
+
failed service takes the whole phase with it — and that is usually what you
|
|
255
|
+
want, because an app that starts on a half-executed plan does not know it.
|
|
256
|
+
- `timeoutMs` is a deadline. A service that never settles would otherwise hold
|
|
257
|
+
the first paint forever.
|
|
258
|
+
|
|
259
|
+
## The host contract
|
|
260
|
+
|
|
261
|
+
```ts
|
|
262
|
+
interface ILankaHost {
|
|
263
|
+
apiBaseUrl: string;
|
|
264
|
+
httpErrorMessage(status: number): string;
|
|
265
|
+
networkErrorMessage(): string;
|
|
266
|
+
timeoutErrorMessage(): string;
|
|
267
|
+
}
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
All four fields are required on purpose. Each is a product decision the framework
|
|
271
|
+
cannot make for you — the copy is yours, the base URL is your build's — and a
|
|
272
|
+
missing one is a compile error in the single place a host is passed, rather than
|
|
273
|
+
an `undefined` inside a request URL.
|
|
274
|
+
|
|
275
|
+
Read it anywhere with `getLankaHost()`.
|
|
276
|
+
|
|
277
|
+
## Flags
|
|
278
|
+
|
|
279
|
+
```ts
|
|
280
|
+
import { getLankaFlags } from "lanka/config";
|
|
281
|
+
|
|
282
|
+
if (getLankaFlags().isDevelopment) {
|
|
283
|
+
// …
|
|
284
|
+
}
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
| Flag | Effect |
|
|
288
|
+
| ------------------------------------------------------------------------------------- | --------------------------------------------------- |
|
|
289
|
+
| `isMockMode` | Gateway mock handlers answer instead of the network |
|
|
290
|
+
| `isProduction` | Silences all log output, disables mocks |
|
|
291
|
+
| `isDevelopment` | Enables development-only behaviour |
|
|
292
|
+
| `loggerEnabled` | Master switch for the logger |
|
|
293
|
+
| `loggerGateway`, `loggerScenario`, `loggerViewModel`, `loggerView`, `loggerBootstrap` | Per-channel switches |
|
|
294
|
+
|
|
295
|
+
Wire them from your bundler: `flags: { isProduction: import.meta.env.PROD }`.
|
|
296
|
+
|
|
297
|
+
## Gateways — talking to a server
|
|
298
|
+
|
|
299
|
+
A gateway states the endpoints an application has and nothing else: no state, no
|
|
300
|
+
error handling, no decisions about what a failure means. Those belong to the
|
|
301
|
+
ViewModel that calls it — which is why gateways stay short.
|
|
302
|
+
|
|
303
|
+
**As a class:**
|
|
304
|
+
|
|
305
|
+
```ts
|
|
306
|
+
import { ALankaGateway, LankaFetchJsonRequest } from "lanka/gateway";
|
|
307
|
+
import type { ILankaTransport } from "lanka/gateway";
|
|
308
|
+
|
|
309
|
+
export class TodoGateway extends ALankaGateway {
|
|
310
|
+
constructor() {
|
|
311
|
+
// No request, no transport: a gateway that says nothing talks JSON over
|
|
312
|
+
// `fetch`. Supply one when this gateway is not ordinary — see below.
|
|
313
|
+
super({ basePath: "/todos" });
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
list(): Promise<ITodo[]> {
|
|
317
|
+
return this.request<ITodo[]>(this.endpoint());
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
byId(id: number): Promise<ITodo> {
|
|
321
|
+
return this.request<ITodo>(this.endpoint(String(id)));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
search(term: string): Promise<ITodo[]> {
|
|
325
|
+
const query = this.buildQueryParams({ q: term, tags: ["open"] });
|
|
326
|
+
return this.request<ITodo[]>(this.endpoint(`?${query.toString()}`));
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
**As a factory** — the same object, without writing a class:
|
|
332
|
+
|
|
333
|
+
```ts
|
|
334
|
+
import { createLankaGateway } from "lanka/gateway";
|
|
335
|
+
|
|
336
|
+
export const createTodoGateway = () =>
|
|
337
|
+
createLankaGateway({
|
|
338
|
+
basePath: "/todos",
|
|
339
|
+
methods: ({ endpoint, request, buildQueryParams }) => ({
|
|
340
|
+
list: () => request<ITodo[]>(endpoint()),
|
|
341
|
+
byId: (id: number) => request<ITodo>(endpoint(String(id))),
|
|
342
|
+
search: (term: string) =>
|
|
343
|
+
request<ITodo[]>(endpoint(`?${buildQueryParams({ q: term }).toString()}`)),
|
|
344
|
+
}),
|
|
345
|
+
});
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
Both build the _same_ class. Pick the one your team reads more easily; a fix to
|
|
349
|
+
the framework reaches both at once.
|
|
350
|
+
|
|
351
|
+
`endpoint(path)` prefixes `apiBaseUrl` and the gateway's `basePath`, normalising
|
|
352
|
+
slashes. Pass an absolute URL and it is used as given.
|
|
353
|
+
|
|
354
|
+
### Requests and transports
|
|
355
|
+
|
|
356
|
+
A **request** decides what a response _is_; a **transport** decides how bytes
|
|
357
|
+
travel. Three of each ship with core:
|
|
358
|
+
|
|
359
|
+
| Request | Answers |
|
|
360
|
+
| --------------------------- | ------------------ |
|
|
361
|
+
| `LankaFetchJsonRequest` | parsed JSON |
|
|
362
|
+
| `LankaFetchRequest` | the raw `Response` |
|
|
363
|
+
| `LankaFetchFormDataRequest` | multipart uploads |
|
|
364
|
+
|
|
365
|
+
Each has a factory twin — `createLankaFetchJsonRequest(…)` and so on — and each
|
|
366
|
+
takes a matching transport (`LankaFetchTransport`, …). Supply your own by
|
|
367
|
+
implementing `ILankaTransport`; the gateway is typed against the port, not the
|
|
368
|
+
implementation.
|
|
369
|
+
|
|
370
|
+
### Request middleware
|
|
371
|
+
|
|
372
|
+
```ts
|
|
373
|
+
const remove = lanka.useRequestMiddleware(async (ctx, next) => {
|
|
374
|
+
const started = performance.now();
|
|
375
|
+
const result = await next(ctx);
|
|
376
|
+
report(ctx.url, performance.now() - started);
|
|
377
|
+
return result;
|
|
378
|
+
});
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
Middleware is a **wrapper**, not a set of hooks, because retry cannot be
|
|
382
|
+
expressed with `onRequest` / `onResponse` / `onError`: an error hook can replace
|
|
383
|
+
an error but cannot run the request again. Registered first wraps the rest. This
|
|
384
|
+
is exactly how [`@lankajs/plugin-http`](https://github.com/lankajs/lanka/blob/main/plugins/http/GUIDE.md) installs retry,
|
|
385
|
+
auth refresh, the CSRF header and idempotency keys.
|
|
386
|
+
|
|
387
|
+
### Cancellation and timeouts
|
|
388
|
+
|
|
389
|
+
`execute` accepts `signal` and `timeoutMs`. The framework merges your signal with
|
|
390
|
+
its own timer into one signal and still tells the two apart: a timed-out request
|
|
391
|
+
raises `timeout` and is shown to the user, a caller-aborted one raises `aborted`
|
|
392
|
+
and stays silent, because the user has already left.
|
|
393
|
+
|
|
394
|
+
### Counting requests in flight
|
|
395
|
+
|
|
396
|
+
```ts
|
|
397
|
+
import { lankaHttpInFlight } from "lanka/gateway";
|
|
398
|
+
|
|
399
|
+
lankaHttpInFlight.subscribe((count) => setBusy(count > 0));
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
## Failure
|
|
403
|
+
|
|
404
|
+
Every failure that leaves a request is a `LankaError` with a **tagged kind**:
|
|
405
|
+
|
|
406
|
+
| Kind | Means | The interface usually |
|
|
407
|
+
| --------- | -------------------------- | --------------------------- |
|
|
408
|
+
| `network` | never reached the server | offers a retry |
|
|
409
|
+
| `timeout` | reached it, no answer came | offers a retry |
|
|
410
|
+
| `aborted` | the caller cancelled | shows nothing |
|
|
411
|
+
| `http` | the server said no | shows the server's message |
|
|
412
|
+
| `schema` | the body did not match | reports a break, not a typo |
|
|
413
|
+
| `domain` | your own rule refused | shows your message |
|
|
414
|
+
|
|
415
|
+
```ts
|
|
416
|
+
import { LankaError, createLankaApiError, handleLankaApiError } from "lanka/errors";
|
|
417
|
+
|
|
418
|
+
try {
|
|
419
|
+
await gateway.list();
|
|
420
|
+
} catch (error) {
|
|
421
|
+
if (error instanceof LankaError && error.kind === "aborted") return;
|
|
422
|
+
setError(handleLankaApiError(error));
|
|
423
|
+
}
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
Refuse locally with the same shape rather than a bare `throw`, so a screen has
|
|
427
|
+
one failure shape to render:
|
|
428
|
+
|
|
429
|
+
```ts
|
|
430
|
+
if (term.trim().length === 0) {
|
|
431
|
+
return Promise.reject(createLankaApiError(400, ["a search needs a term"]));
|
|
432
|
+
}
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
## Validating a response
|
|
436
|
+
|
|
437
|
+
The validator accepts **any** schema implementing
|
|
438
|
+
[Standard Schema](https://standardschema.dev) — zod 4, valibot, arktype. There
|
|
439
|
+
are no adapter classes: a schema describes itself.
|
|
440
|
+
|
|
441
|
+
```ts
|
|
442
|
+
async listValidated(): Promise<ITodo[]> {
|
|
443
|
+
const body = await this.request<unknown>(this.endpoint());
|
|
444
|
+
return lankaStandardValidator.validate(todoSchema, body, "todos.list");
|
|
445
|
+
}
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
The third argument is a label. It appears in the `LankaValidationError` and in
|
|
449
|
+
the log, and it is what turns "invalid response" into "which call".
|
|
450
|
+
|
|
451
|
+
Because Standard Schema's `validate` returns the _transformed_ value, mapping a
|
|
452
|
+
legacy wire format is just a second schema — there is no adapter layer, because
|
|
453
|
+
there is nothing for it to do:
|
|
454
|
+
|
|
455
|
+
```ts
|
|
456
|
+
const domain = lankaStandardValidator.validate(todoApiSchema, wire, "todos.map");
|
|
457
|
+
return lankaStandardValidator.validate(todoSchema, domain, "todos.check");
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
An async schema is rejected loudly. A synchronous port cannot await one, and
|
|
461
|
+
answering "fine" would let unvalidated data through.
|
|
462
|
+
|
|
463
|
+
## ViewModels
|
|
464
|
+
|
|
465
|
+
A ViewModel owns a screen's state and the actions that change it. What you get
|
|
466
|
+
back is a React hook: `const { todos, isLoading, load } = useTodosVM();`
|
|
467
|
+
|
|
468
|
+
**As a factory:**
|
|
469
|
+
|
|
470
|
+
```ts
|
|
471
|
+
import { createLankaVM } from "lanka/viewmodel";
|
|
472
|
+
|
|
473
|
+
export const createTodosVM = (todoGateway: TodoGateway) =>
|
|
474
|
+
createLankaVM<ITodosState, ITodoActions, { todoGateway: TodoGateway }>({
|
|
475
|
+
name: "TodosVM",
|
|
476
|
+
states: { todos: [], error: null, isLoading: false },
|
|
477
|
+
gateways: () => ({ todoGateway }),
|
|
478
|
+
|
|
479
|
+
createActions: ({ set, get, gateways, trigger }) => ({
|
|
480
|
+
load: async () => {
|
|
481
|
+
set({ isLoading: true, error: null });
|
|
482
|
+
try {
|
|
483
|
+
set({ todos: await gateways.todoGateway.list() });
|
|
484
|
+
} finally {
|
|
485
|
+
set({ isLoading: false });
|
|
486
|
+
}
|
|
487
|
+
},
|
|
488
|
+
complete: (id: number) => {
|
|
489
|
+
set({ todos: markDone(get().todos, id) });
|
|
490
|
+
trigger(todoCompleted, { id });
|
|
491
|
+
},
|
|
492
|
+
}),
|
|
493
|
+
|
|
494
|
+
scenarioHandlers: [
|
|
495
|
+
{
|
|
496
|
+
scenario: todoCompleted,
|
|
497
|
+
handler:
|
|
498
|
+
({ set, get }) =>
|
|
499
|
+
(data?: { id: number }) => {
|
|
500
|
+
if (!data) return;
|
|
501
|
+
set({ todos: markDone(get().todos, data.id) });
|
|
502
|
+
},
|
|
503
|
+
},
|
|
504
|
+
],
|
|
505
|
+
});
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
**As a class** — same behaviour, written against `this`:
|
|
509
|
+
|
|
510
|
+
```ts
|
|
511
|
+
import { ALankaVM } from "lanka/viewmodel";
|
|
512
|
+
|
|
513
|
+
export class TodosVM extends ALankaVM<ITodosState, ITodoActions, ITodoGateways> {
|
|
514
|
+
protected readonly name = "TodosVM";
|
|
515
|
+
|
|
516
|
+
private readonly gateway: TodoGateway;
|
|
517
|
+
|
|
518
|
+
constructor(gateway: TodoGateway) {
|
|
519
|
+
super();
|
|
520
|
+
this.gateway = gateway;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
protected override states(): ITodosState {
|
|
524
|
+
return { todos: [], error: null, isLoading: false };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
protected override createGateways(): ITodoGateways {
|
|
528
|
+
return { todoGateway: this.gateway };
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
protected createActions(): ITodoActions {
|
|
532
|
+
return {
|
|
533
|
+
load: async () => {
|
|
534
|
+
this.set({ isLoading: true });
|
|
535
|
+
try {
|
|
536
|
+
this.set({ todos: await this.gateways.todoGateway.list() });
|
|
537
|
+
} finally {
|
|
538
|
+
this.set({ isLoading: false });
|
|
539
|
+
}
|
|
540
|
+
},
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
export const useTodosVM = new TodosVM(gateway).build();
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
The protected surface is exactly the factory's context — `set`, `get`,
|
|
549
|
+
`gateways`, `services`, `trigger` — and that is enforced, not a convention. The
|
|
550
|
+
overridable hooks are `states`, `createGateways`, `createServices`,
|
|
551
|
+
`scenarioHandlers`, `enhancers`, `onInit`, `onReset` and `createActions`.
|
|
552
|
+
|
|
553
|
+
### Config reference
|
|
554
|
+
|
|
555
|
+
| Field | Meaning |
|
|
556
|
+
| ---------------------------------- | --------------------------------------------------- |
|
|
557
|
+
| `name` | Shown in logs and devtools. Required |
|
|
558
|
+
| `states` | The initial state. Omit for a stateless ViewModel |
|
|
559
|
+
| `createActions` | Receives the context, returns the actions |
|
|
560
|
+
| `gateways` / `services` | An object or a factory; reachable as `gateways.x` |
|
|
561
|
+
| `scenarioHandlers` | `{ scenario, handler }` pairs, bound at bootstrap |
|
|
562
|
+
| `enhancers` | Store enhancers, zustand style |
|
|
563
|
+
| `onInit` / `onReset` | Lifecycle hooks over the same context |
|
|
564
|
+
| `enableAccessTrackingOptimization` | Default on; see [common mistakes](#common-mistakes) |
|
|
565
|
+
|
|
566
|
+
### Using one in a component
|
|
567
|
+
|
|
568
|
+
```tsx
|
|
569
|
+
const TodoScreen = () => {
|
|
570
|
+
const { todos, isLoading, load } = useTodosVM();
|
|
571
|
+
useEffect(() => {
|
|
572
|
+
void load();
|
|
573
|
+
}, [load]);
|
|
574
|
+
return isLoading ? <Spinner /> : <List items={todos} />;
|
|
575
|
+
};
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
The hook re-renders a component only for the keys it actually **read**. That is
|
|
579
|
+
usually free performance, and it has one blind spot, described at the end of this
|
|
580
|
+
guide.
|
|
581
|
+
|
|
582
|
+
## Choosing a coordination tool
|
|
583
|
+
|
|
584
|
+
When "A must affect B", one question decides it: **do A and B co-own a thing, or
|
|
585
|
+
does A announce something to independent Bs?**
|
|
586
|
+
|
|
587
|
+
| The link | The tool |
|
|
588
|
+
| ---------------------------------------------------------- | ------------------ |
|
|
589
|
+
| one screen owns the state and nothing else needs it | one ViewModel |
|
|
590
|
+
| A announces a fact; B, C and D each react in their own way | a **scenario** |
|
|
591
|
+
| A and B edit the same in-flight thing before it is saved | a **shared store** |
|
|
592
|
+
|
|
593
|
+
Read it in that order and stop at the first that fits. The commonest mistake is
|
|
594
|
+
reaching past the first two: two ViewModels wired directly, or a shared store for
|
|
595
|
+
what is really an announcement.
|
|
596
|
+
|
|
597
|
+
A scenario carries something that **happened**. A shared store holds something
|
|
598
|
+
being **co-edited** — a two-step form's draft, a selection two panels must agree
|
|
599
|
+
on. If you would describe the link with a verb in the past tense, it is a
|
|
600
|
+
scenario.
|
|
601
|
+
|
|
602
|
+
## Shared stores
|
|
603
|
+
|
|
604
|
+
When two screens must not disagree — a selection, a draft, a filter — put the
|
|
605
|
+
state in a shared store and give each screen its own ViewModel over it.
|
|
606
|
+
|
|
607
|
+
```ts
|
|
608
|
+
import { ALankaSharedStore, ALankaSharedStoreVM } from "lanka/viewmodel";
|
|
609
|
+
|
|
610
|
+
export class TodoStore extends ALankaSharedStore<ISelection> {
|
|
611
|
+
constructor() {
|
|
612
|
+
super(() => ({ selectedId: null }));
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
export class BadgeVM extends ALankaSharedStoreVM<ISelection, IBadgeActions, TodoStore> {
|
|
617
|
+
protected readonly name = "BadgeVM";
|
|
618
|
+
|
|
619
|
+
protected createActions(): IBadgeActions {
|
|
620
|
+
return {
|
|
621
|
+
select: (id: number) => {
|
|
622
|
+
this.set({ selectedId: id });
|
|
623
|
+
},
|
|
624
|
+
clear: () => {
|
|
625
|
+
this.set({ selectedId: null });
|
|
626
|
+
},
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
```
|
|
631
|
+
|
|
632
|
+
The factory twins are `createLankaSharedStore` and `createSharedStoreLankaVM`.
|
|
633
|
+
|
|
634
|
+
Each ViewModel owns its **actions**; the **state** lives once, in the store, so
|
|
635
|
+
two readers cannot drift apart. Passing the value down as props works for one hop
|
|
636
|
+
and breaks at the second; duplicating it into both ViewModels produces two
|
|
637
|
+
answers to one question.
|
|
638
|
+
|
|
639
|
+
## Stateless ViewModels
|
|
640
|
+
|
|
641
|
+
Roughly half the ViewModels an application writes hold nothing — they answer
|
|
642
|
+
questions about data somebody else owns. Give them no store at all, or every
|
|
643
|
+
consumer re-renders on changes to a state that cannot change.
|
|
644
|
+
|
|
645
|
+
```ts
|
|
646
|
+
import { createStatelessLankaVM } from "lanka/viewmodel";
|
|
647
|
+
|
|
648
|
+
export const createStatsVM = () =>
|
|
649
|
+
createStatelessLankaVM<IStatsActions>({
|
|
650
|
+
name: "StatsVM",
|
|
651
|
+
createActions: () => ({
|
|
652
|
+
countDone: (todos: readonly ITodo[]) => todos.filter((todo) => todo.done).length,
|
|
653
|
+
}),
|
|
654
|
+
});
|
|
655
|
+
```
|
|
656
|
+
|
|
657
|
+
The class twin is `ALankaStatelessVM`.
|
|
658
|
+
|
|
659
|
+
## Lazy ViewModels
|
|
660
|
+
|
|
661
|
+
`createLazyLankaVM`, `createLazyStatelessLankaVM` and
|
|
662
|
+
`createLazySharedStoreLankaVM` build on first use rather than at module load. Use
|
|
663
|
+
them for a screen behind a route most sessions never open.
|
|
664
|
+
|
|
665
|
+
```ts
|
|
666
|
+
const useSettingsVM = createLazyLankaVM({/* … */});
|
|
667
|
+
// nothing is built yet
|
|
668
|
+
useSettingsVM.dispose(); // drops the store and its subscriptions
|
|
669
|
+
```
|
|
670
|
+
|
|
671
|
+
**Lazy variants are factory-only, deliberately.** Lazy is a _lifetime_, not a
|
|
672
|
+
role — there is nothing extra to subclass.
|
|
673
|
+
|
|
674
|
+
`dispose()` matters here: a lazy ViewModel subscribes to scenarios on first use,
|
|
675
|
+
and without explicit disposal that subscription outlives the screen.
|
|
676
|
+
|
|
677
|
+
## Scenarios — cross-screen facts
|
|
678
|
+
|
|
679
|
+
A scenario names a **fact**, not an action: `TodoCompleted`, not `CompleteTodo`.
|
|
680
|
+
Whoever changes something triggers it; whoever cares subscribes. Neither screen
|
|
681
|
+
imports the other — that is the whole reason the layer exists.
|
|
682
|
+
|
|
683
|
+
```ts
|
|
684
|
+
import { ALankaScenario } from "lanka/scenario";
|
|
685
|
+
|
|
686
|
+
export class TodoCompleted extends ALankaScenario<{ id: number }> {
|
|
687
|
+
readonly name = "TodoCompleted";
|
|
688
|
+
readonly eventType = "todo:completed";
|
|
689
|
+
readonly dataTypeName = "ITodoCompleted";
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
export const todoCompleted = new TodoCompleted();
|
|
693
|
+
```
|
|
694
|
+
|
|
695
|
+
Keep the single instance beside its class. A second instance is a second event
|
|
696
|
+
nobody listens to — silence, not an error.
|
|
697
|
+
|
|
698
|
+
**As a factory**, when the body is only data, which is most of the time:
|
|
699
|
+
|
|
700
|
+
```ts
|
|
701
|
+
import { createLankaScenario } from "lanka/scenario";
|
|
702
|
+
|
|
703
|
+
export const todoCompleted = createLankaScenario<{ id: number }>({
|
|
704
|
+
name: "TodoCompleted",
|
|
705
|
+
eventType: "todo:completed",
|
|
706
|
+
dataTypeName: "ITodoCompleted",
|
|
707
|
+
});
|
|
708
|
+
```
|
|
709
|
+
|
|
710
|
+
A ViewModel subscribes through `scenarioHandlers` and fires through
|
|
711
|
+
`trigger(scenario, data)`.
|
|
712
|
+
|
|
713
|
+
> [!NOTE]
|
|
714
|
+
> Binding happens inside `bootstrap()`, which `startLanka` awaits for you. What
|
|
715
|
+
> matters is that a ViewModel carrying `scenarioHandlers` exists **before** that
|
|
716
|
+
> runs — bootstrap binds what exists when it runs.
|
|
717
|
+
|
|
718
|
+
### The bus directly
|
|
719
|
+
|
|
720
|
+
```ts
|
|
721
|
+
import { lankaEventBus } from "lanka/scenario";
|
|
722
|
+
|
|
723
|
+
const off = lankaEventBus.subscribe<ITodo>("todo:completed", onCompleted, {
|
|
724
|
+
replay: "last",
|
|
725
|
+
priority: 10,
|
|
726
|
+
});
|
|
727
|
+
```
|
|
728
|
+
|
|
729
|
+
Things worth knowing:
|
|
730
|
+
|
|
731
|
+
- **Buffering happens on request only.** Depth is the maximum of what the event
|
|
732
|
+
declares (`registerEvent({ replay: N })`) and what subscribers ask for; when
|
|
733
|
+
the last asker leaves, the buffer is cleared. Nothing is buffered "just in
|
|
734
|
+
case", because that means holding personal data in memory with no consumer.
|
|
735
|
+
- **`replay` counts values:** `false | "last" | N`.
|
|
736
|
+
- **`subscribe` returns an unsubscribe** that removes exactly that subscription.
|
|
737
|
+
Removing by callback cannot tell two subscriptions of one function apart.
|
|
738
|
+
- **Delivery iterates a copy** of the subscriber list, so a handler may
|
|
739
|
+
unsubscribe itself mid-delivery. The deliberate consequence: subscribing
|
|
740
|
+
_during_ delivery waits for the next event.
|
|
741
|
+
- **Middleware returns a decision** — `"pass"` or `{ stop: reason }` — never
|
|
742
|
+
`next()`. A middleware that forgot to call `next()` would make the event vanish
|
|
743
|
+
silently, and a mechanism that exists for observability must not be its own
|
|
744
|
+
blind spot. A stop is written to the event log; so is a throw.
|
|
745
|
+
|
|
746
|
+
## The locator
|
|
747
|
+
|
|
748
|
+
Four registries, one per kind of object, each reachable by name — so a screen
|
|
749
|
+
never learns where an object came from, which is what makes it replaceable in a
|
|
750
|
+
test.
|
|
751
|
+
|
|
752
|
+
```ts
|
|
753
|
+
lanka.locators.singletons.register("SessionService", SessionService);
|
|
754
|
+
lanka.locators.gateways.registerInstance("TodoGateway", todoGateway);
|
|
755
|
+
|
|
756
|
+
const session = lanka.resolve<SessionService>("sessionService");
|
|
757
|
+
```
|
|
758
|
+
|
|
759
|
+
Ambient access, for code that cannot hold an instance:
|
|
760
|
+
|
|
761
|
+
```ts
|
|
762
|
+
import { lankaSingletons, lankaGateways } from "lanka/locator";
|
|
763
|
+
|
|
764
|
+
lankaSingletons.sessionService.signIn("ada");
|
|
765
|
+
void lankaGateways.todoGateway.list();
|
|
766
|
+
```
|
|
767
|
+
|
|
768
|
+
A name nobody registered is a **named refusal**, not `undefined`.
|
|
769
|
+
|
|
770
|
+
### Declaring a singleton
|
|
771
|
+
|
|
772
|
+
```ts
|
|
773
|
+
import { ALankaSingleton, createLankaSingleton } from "lanka/locator";
|
|
774
|
+
|
|
775
|
+
export class SessionService extends ALankaSingleton {
|
|
776
|
+
private who: string | null = null;
|
|
777
|
+
|
|
778
|
+
signIn(name: string): void {
|
|
779
|
+
this.who = name;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
export const Clock = createLankaSingleton<IClock>(() => {
|
|
784
|
+
let seen = 0;
|
|
785
|
+
return { ticks: () => (seen += 1) };
|
|
786
|
+
});
|
|
787
|
+
```
|
|
788
|
+
|
|
789
|
+
Extending the marker is not ceremony. Without it, "a singleton" means "any
|
|
790
|
+
exported function with a prototype", and a stray export in a barrel silently
|
|
791
|
+
becomes part of the public `lankaSingletons.*`.
|
|
792
|
+
|
|
793
|
+
### Typed ambient access
|
|
794
|
+
|
|
795
|
+
`lankaGateways.todoGateway` is typed when your application publishes `.lanka_di/`
|
|
796
|
+
barrels — see [`@lankajs/tool-di`](https://github.com/lankajs/lanka/blob/main/tools/di/GUIDE.md). Without them the
|
|
797
|
+
facades still work; they are just untyped.
|
|
798
|
+
|
|
799
|
+
## Scopes
|
|
800
|
+
|
|
801
|
+
```ts
|
|
802
|
+
const scope = lanka.createScope();
|
|
803
|
+
const vm = scope.resolve<ScreenVM>("screenVM");
|
|
804
|
+
scope.dispose();
|
|
805
|
+
```
|
|
806
|
+
|
|
807
|
+
A scope is a lifetime shorter than the application's: what it created goes away
|
|
808
|
+
with it. It resolves **only its own** objects — a scope that could hand out
|
|
809
|
+
others' would make closing a screen break the app. Resolving from a closed scope
|
|
810
|
+
is refused loudly, because it is almost always a reference that outlived its
|
|
811
|
+
screen.
|
|
812
|
+
|
|
813
|
+
## Mock mode
|
|
814
|
+
|
|
815
|
+
```ts
|
|
816
|
+
list(): Promise<ITodo[]> {
|
|
817
|
+
const mock = createLankaMockHandler(
|
|
818
|
+
() => import("../mocks/todoMocks"),
|
|
819
|
+
(module) => module.todoMocks,
|
|
820
|
+
"todos.list",
|
|
821
|
+
0,
|
|
822
|
+
);
|
|
823
|
+
if (mock) return mock();
|
|
824
|
+
|
|
825
|
+
return this.request<ITodo[]>(this.endpoint());
|
|
826
|
+
}
|
|
827
|
+
```
|
|
828
|
+
|
|
829
|
+
`createLankaMockHandler` returns `undefined` while mock mode is off — so the
|
|
830
|
+
branch disappears, the dynamic import is never reached, and **a production bundle
|
|
831
|
+
contains no mock data**, because nothing in it can refer to the module.
|
|
832
|
+
|
|
833
|
+
Always pass the name. Deriving it from the stack trace works only until
|
|
834
|
+
minification, and mock mode is enabled in exactly such a bundle.
|
|
835
|
+
|
|
836
|
+
## The logger
|
|
837
|
+
|
|
838
|
+
```ts
|
|
839
|
+
import { lankaLogger } from "lanka/logger";
|
|
840
|
+
|
|
841
|
+
lankaLogger.printGatewayLog("GET /todos", payload);
|
|
842
|
+
```
|
|
843
|
+
|
|
844
|
+
Channels follow the flags: `loggerGateway`, `loggerScenario`, `loggerViewModel`,
|
|
845
|
+
`loggerView` and `loggerBootstrap`, all under `loggerEnabled`, and all silent
|
|
846
|
+
when `isProduction`. Add a sink (`ILankaLoggerSink`) to ship lines somewhere
|
|
847
|
+
other than the console.
|
|
848
|
+
|
|
849
|
+
## Plugins
|
|
850
|
+
|
|
851
|
+
A plugin is something **core** calls (`app → core → plugin`); a module is
|
|
852
|
+
something **you** call (`app → module`). If core needs no hook for it, it is a
|
|
853
|
+
module — and it should stay one, because every extension point is a promise for
|
|
854
|
+
the lifetime of a major version.
|
|
855
|
+
|
|
856
|
+
```ts
|
|
857
|
+
import { ALankaPlugin } from "lanka";
|
|
858
|
+
|
|
859
|
+
class TimingPlugin extends ALankaPlugin {
|
|
860
|
+
readonly name = "timing";
|
|
861
|
+
|
|
862
|
+
install(lanka: ILankaInstance) {
|
|
863
|
+
return lanka.useRequestMiddleware(timing); // the returned function uninstalls
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
const remove = lanka.use(new TimingPlugin());
|
|
868
|
+
```
|
|
869
|
+
|
|
870
|
+
`install` receives the instance so a plugin has no private route into the
|
|
871
|
+
framework, and two instances in one process do not share its configuration.
|
|
872
|
+
Registering the same name twice is refused — two copies of a retry policy would
|
|
873
|
+
silently double the request count.
|
|
874
|
+
|
|
875
|
+
## Roles — both styles for your own layer
|
|
876
|
+
|
|
877
|
+
Every role in lanka ships as a class _and_ a factory over one implementation. You
|
|
878
|
+
can have the same for a layer of your own — a repository, a presenter, a command:
|
|
879
|
+
|
|
880
|
+
```ts
|
|
881
|
+
import { defineLankaRole } from "lanka/role";
|
|
882
|
+
|
|
883
|
+
export const createRepository = defineLankaRole(openRepository);
|
|
884
|
+
```
|
|
885
|
+
|
|
886
|
+
`openRepository` builds the class and hands back `{ instance, context }`, where
|
|
887
|
+
`context` is its protected surface. Write it in the role's own module: `protected`
|
|
888
|
+
is readable only from inside a deriving class body, so a generic helper outside
|
|
889
|
+
the hierarchy cannot reach it.
|
|
890
|
+
|
|
891
|
+
## Import map
|
|
892
|
+
|
|
893
|
+
| Import | Contains |
|
|
894
|
+
| ------------------ | -------------------------------------------------------------------------- |
|
|
895
|
+
| `lanka` | `createLanka`, `LankaError`, `lankaLogger`, flags, host, `defineLankaRole` |
|
|
896
|
+
| `lanka/bootstrap` | `createLanka`, `ALankaPlugin`, `resetActiveLanka` |
|
|
897
|
+
| `lanka/config` | `getLankaHost`, `getLankaFlags` |
|
|
898
|
+
| `lanka/errors` | `LankaError`, `createLankaApiError`, `handleLankaApiError` |
|
|
899
|
+
| `lanka/gateway` | gateways, requests, transports, query params, the in-flight counter |
|
|
900
|
+
| `lanka/locator` | singletons, and the four ambient facades |
|
|
901
|
+
| `lanka/logger` | `lankaLogger`, `LankaLogger`, sinks |
|
|
902
|
+
| `lanka/mock` | `createLankaMockHandler` |
|
|
903
|
+
| `lanka/role` | `defineLankaRole` |
|
|
904
|
+
| `lanka/scenario` | scenarios, the event bus, scenario bootstrap |
|
|
905
|
+
| `lanka/validation` | `lankaStandardValidator`, `LankaValidationError` |
|
|
906
|
+
| `lanka/viewmodel` | every ViewModel shape, and shared stores |
|
|
907
|
+
| `lanka/extend` | mechanism for tooling and alternative implementations. Changes in a minor |
|
|
908
|
+
| `lanka/internal` | primitives shared between lanka packages. Changes in any release |
|
|
909
|
+
|
|
910
|
+
Names under the `lanka/*` facades are kept until a major version and are never
|
|
911
|
+
removed. Reaching past the facade is possible, deliberate and visible in review —
|
|
912
|
+
which beats making it impossible and having people fork the framework.
|
|
913
|
+
|
|
914
|
+
## What else ships
|
|
915
|
+
|
|
916
|
+
Core is enough to build an application. Four packages exist to make working on
|
|
917
|
+
one easier, and each is optional.
|
|
918
|
+
|
|
919
|
+
| Package | What it does for you |
|
|
920
|
+
| -------------------------------------------------- | ----------------------------------------------------------------------------- |
|
|
921
|
+
| [`@lankajs/tool-di`](https://github.com/lankajs/lanka/blob/main/tools/di/GUIDE.md) | The `@lanka_di` alias and the barrels. Effectively required in a vite app |
|
|
922
|
+
| [`@lankajs/tool-eslint`](https://github.com/lankajs/lanka/blob/main/tools/eslint/GUIDE.md) | The boundaries above, as lint rules that name the file and the line |
|
|
923
|
+
| [`@lankajs/tool-testing`](https://github.com/lankajs/lanka/blob/main/tools/testing/GUIDE.md) | A fresh framework per test, a render helper, two doubles, the bench yardstick |
|
|
924
|
+
| [`@lankajs/tool-skills`](https://github.com/lankajs/lanka/blob/main/tools/skills/GUIDE.md) | The agent skills of the packages you installed, in your project |
|
|
925
|
+
|
|
926
|
+
### Skills for your coding agent
|
|
927
|
+
|
|
928
|
+
Every package ships a skill: what it is for, the shapes to write, and the
|
|
929
|
+
refusals — the things that look like a missing feature and are the feature. Two
|
|
930
|
+
ways to install one:
|
|
931
|
+
|
|
932
|
+
```bash
|
|
933
|
+
# from the framework's repository, in Claude Code
|
|
934
|
+
/plugin marketplace add lankajs/lanka
|
|
935
|
+
/plugin install lanka-core@lankajs
|
|
936
|
+
|
|
937
|
+
# or from the packages you already installed — the skill for THAT version
|
|
938
|
+
npx lanka-skills sync
|
|
939
|
+
```
|
|
940
|
+
|
|
941
|
+
> [!TIP]
|
|
942
|
+
> Prefer the second where it works. A skill installed from git describes the main
|
|
943
|
+
> branch; one installed from your `node_modules` describes the code you are
|
|
944
|
+
> actually running. Start with `lanka-packages`, which routes to the rest by
|
|
945
|
+
> problem.
|
|
946
|
+
|
|
947
|
+
### The modules and plugins
|
|
948
|
+
|
|
949
|
+
Nine more packages solve problems you may or may not have — realtime, retry
|
|
950
|
+
policy, optimistic updates, list handling, storage, prefetching. The table of
|
|
951
|
+
"add it when" is in [ARCHITECTURE.md](https://github.com/lankajs/lanka/blob/main/ARCHITECTURE.md#adopting-the-packages),
|
|
952
|
+
and each has a guide of its own.
|
|
953
|
+
|
|
954
|
+
## Cases
|
|
955
|
+
|
|
956
|
+
### A screen that loads a list
|
|
957
|
+
|
|
958
|
+
1. **Gateway** — one method per endpoint, validating the body.
|
|
959
|
+
2. **ViewModel** — `states` for the list, the flag and the error; an action that
|
|
960
|
+
sets the flag, calls the gateway, and clears the flag in `finally`.
|
|
961
|
+
3. **Component** — reads the hook, calls the action in an effect, renders three
|
|
962
|
+
states: loading, error, content.
|
|
963
|
+
|
|
964
|
+
Nothing else. If you find yourself writing a `useState` for the rows beside the
|
|
965
|
+
ViewModel, the ViewModel is the one that should hold them.
|
|
966
|
+
|
|
967
|
+
### One screen changes something another must see
|
|
968
|
+
|
|
969
|
+
The change happens in the owner's ViewModel, and it announces a fact:
|
|
970
|
+
|
|
971
|
+
```ts
|
|
972
|
+
complete: async (id: number) => {
|
|
973
|
+
await gateways.todoGateway.complete(id);
|
|
974
|
+
set({ todos: markDone(get().todos, id) });
|
|
975
|
+
trigger(todoCompleted, { id }); // ← the announcement
|
|
976
|
+
};
|
|
977
|
+
```
|
|
978
|
+
|
|
979
|
+
Every other ViewModel that cares subscribes through `scenarioHandlers` and
|
|
980
|
+
decides for itself what the fact means for its own state. Neither imports the
|
|
981
|
+
other.
|
|
982
|
+
|
|
983
|
+
**Trigger with the data when you have it.** If the server returned the updated
|
|
984
|
+
object, put it in the payload — subscribers apply it instead of each refetching.
|
|
985
|
+
If the call returned nothing, trigger the bare fact and let each subscriber
|
|
986
|
+
decide whether it needs a read.
|
|
987
|
+
|
|
988
|
+
### A two-step form, and the world afterwards
|
|
989
|
+
|
|
990
|
+
The case people get wrong, because it needs **three different tools at once**:
|
|
991
|
+
|
|
992
|
+
| The link | The tool |
|
|
993
|
+
| ---------------------------------------------------- | ------------------ |
|
|
994
|
+
| step 1 and step 2 edit one unsaved draft | a **shared store** |
|
|
995
|
+
| "order placed" must clear a badge in the header | a **scenario** |
|
|
996
|
+
| "order placed" must refresh a list on another screen | the same scenario |
|
|
997
|
+
|
|
998
|
+
```
|
|
999
|
+
AddressStep ─┐
|
|
1000
|
+
├─► CheckoutStore (shared) ← one draft, two editors
|
|
1001
|
+
PaymentStep ─┘
|
|
1002
|
+
│ user presses "Place order"
|
|
1003
|
+
▼
|
|
1004
|
+
PaymentStepVM.placeOrder()
|
|
1005
|
+
├─► orderGateway.place(draft) ← the only network call
|
|
1006
|
+
├─► trigger(orderPlaced, { order }) ← ONE announcement…
|
|
1007
|
+
│ ├─► CartBadgeVM (subscribed) → clears the badge
|
|
1008
|
+
│ └─► OrdersListVM (subscribed) → applies the order
|
|
1009
|
+
├─► clear the store (the draft is spent)
|
|
1010
|
+
└─► navigate to the confirmation
|
|
1011
|
+
```
|
|
1012
|
+
|
|
1013
|
+
Three things worth saying out loud:
|
|
1014
|
+
|
|
1015
|
+
- **A shared store between the steps, not a scenario.** They co-own one buffer;
|
|
1016
|
+
a scenario is for "this happened", not "we are editing the same thing".
|
|
1017
|
+
- **A scenario for the badge, not a shared store.** Those are independent owners.
|
|
1018
|
+
- **The orchestration lives in the ViewModel**, not in the button. The component
|
|
1019
|
+
calls `placeOrder()` and stays dumb.
|
|
1020
|
+
|
|
1021
|
+
And one that only bites later: if the confirmation screen's ViewModel is
|
|
1022
|
+
**lazy**, it is not subscribed yet, so the scenario will not reach it. Write what
|
|
1023
|
+
it needs before navigating, or pass an id in the route.
|
|
1024
|
+
|
|
1025
|
+
### A screen behind a route most sessions never open
|
|
1026
|
+
|
|
1027
|
+
`createLazyLankaVM`, and `dispose()` when the screen goes. Otherwise its scenario
|
|
1028
|
+
subscriptions outlive it, and it keeps reacting to facts about a screen nobody is
|
|
1029
|
+
looking at.
|
|
1030
|
+
|
|
1031
|
+
### A service several screens need
|
|
1032
|
+
|
|
1033
|
+
Not an import — a name. Register it once at start-up
|
|
1034
|
+
(`lanka.locators.singletons.register(...)`), reach it as
|
|
1035
|
+
`lankaSingletons.sessionService`, and a test replaces the object behind the name
|
|
1036
|
+
without touching a screen.
|
|
1037
|
+
|
|
1038
|
+
## Common mistakes
|
|
1039
|
+
|
|
1040
|
+
**Building a ViewModel before the framework exists.** It resolves against no
|
|
1041
|
+
runtime. `startLanka` — or `createLanka` — comes first.
|
|
1042
|
+
|
|
1043
|
+
**A scenario handler that never fires.** Either `bootstrap()` was never awaited,
|
|
1044
|
+
or the ViewModel was constructed after it: bootstrap binds what exists when it
|
|
1045
|
+
runs.
|
|
1046
|
+
|
|
1047
|
+
**Reading state through a getter and wondering why the screen froze.** A consumer
|
|
1048
|
+
re-renders only for the keys it _read through the proxy_. If a component's only
|
|
1049
|
+
link to `todos` goes through a getter that calls `get()` internally, that read
|
|
1050
|
+
happens past the proxy and no re-render follows. There is no error.
|
|
1051
|
+
|
|
1052
|
+
In development the framework notices and warns, naming the ViewModel and the key.
|
|
1053
|
+
The fix is `enableAccessTrackingOptimization: false` on that ViewModel. Do **not**
|
|
1054
|
+
"fix" it by destructuring for the side effect: that reads as dead code, and the
|
|
1055
|
+
next refactor or lint autofix deletes it.
|
|
1056
|
+
|
|
1057
|
+
**Naming a scenario after an action.** `CompleteTodo` invites the subscriber to
|
|
1058
|
+
_do_ the completing; `TodoCompleted` states what happened. The second is what
|
|
1059
|
+
lets three screens react differently without knowing about each other.
|
|
1060
|
+
|
|
1061
|
+
**Letting a lazy ViewModel leak.** Call `dispose()` when its screen goes away.
|
|
1062
|
+
|
|
1063
|
+
**Validating in the screen instead of the gateway.** A gateway is where a body
|
|
1064
|
+
stops being `unknown`. Validate anywhere else and the same three guards end up
|
|
1065
|
+
spread over every consumer, each slightly differently wrong.
|
|
1066
|
+
|
|
1067
|
+
## Recap
|
|
1068
|
+
|
|
1069
|
+
- **`startLanka({ apiBaseUrl })`** is the whole start-up: create, activate,
|
|
1070
|
+
install plugins, bootstrap. Write the two calls out only when something must
|
|
1071
|
+
happen between them.
|
|
1072
|
+
- **Imports go one way.** A ViewModel reaches a gateway; a gateway does not know
|
|
1073
|
+
ViewModels exist.
|
|
1074
|
+
- **A gateway states endpoints** and holds no state — it is also where a body
|
|
1075
|
+
stops being `unknown`.
|
|
1076
|
+
- **A ViewModel owns a screen's state**, and comes in three shapes: stateful,
|
|
1077
|
+
stateless, and over a shared store. Each ships as a class and a factory over
|
|
1078
|
+
one implementation.
|
|
1079
|
+
- **For "A must affect B"**, ask whether they co-own a thing (a shared store) or
|
|
1080
|
+
A announces something (a scenario). Never an import between ViewModels.
|
|
1081
|
+
- **`LankaError` carries a kind**, because `aborted` and `network` need different
|
|
1082
|
+
things from the interface.
|
|
1083
|
+
- **What fails loudly does so on purpose**: a name nobody registered, an async
|
|
1084
|
+
schema, a mock in production. Those are refusals, not gaps.
|
|
1085
|
+
|
|
1086
|
+
---
|
|
1087
|
+
|
|
1088
|
+
Recommended architecture: [ARCHITECTURE.md](https://github.com/lankajs/lanka/blob/main/ARCHITECTURE.md) · Testing
|
|
1089
|
+
helpers: [`@lankajs/tool-testing`](https://github.com/lankajs/lanka/blob/main/tools/testing/GUIDE.md) · Maintaining
|
|
1090
|
+
this package: [SKILL.md](https://github.com/lankajs/lanka/blob/main/core/SKILL.md) · Repository map: [../README.md](https://github.com/lankajs/lanka/blob/main/README.md)
|