@finesoft/front 0.1.76 → 0.1.78
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/docs/01-getting-started.md +230 -0
- package/docs/02-routing-and-controllers.md +203 -0
- package/docs/03-middleware.md +220 -0
- package/docs/04-rendering-and-hydration.md +271 -0
- package/docs/05-i18n.md +243 -0
- package/docs/06-http-client.md +286 -0
- package/docs/07-di-container.md +264 -0
- package/docs/08-observability.md +290 -0
- package/docs/09-server-and-deployment.md +242 -0
- package/docs/10-features-platform-pwa.md +238 -0
- package/docs/README.md +72 -0
- package/docs/advanced/custom-action-handler.md +248 -0
- package/docs/advanced/custom-adapter.md +264 -0
- package/docs/advanced/custom-event-recorder.md +318 -0
- package/docs/advanced/inline-proxy-codegen.md +200 -0
- package/docs/advanced/multi-tenant-scopes.md +330 -0
- package/docs/engineering/ci-release-flow.md +244 -0
- package/docs/engineering/project-structure.md +296 -0
- package/docs/engineering/testing.md +317 -0
- package/docs/pitfalls/container-scope-leak.md +215 -0
- package/docs/pitfalls/i18n-bundle-size.md +182 -0
- package/docs/pitfalls/proxy-binary-payloads.md +133 -0
- package/docs/pitfalls/redirect-vs-rewrite.md +147 -0
- package/docs/pitfalls/ssr-hydration-mismatch.md +163 -0
- package/docs/pitfalls/ssr-vs-csr-globals.md +176 -0
- package/docs/zh/01-getting-started.md +230 -0
- package/docs/zh/02-routing-and-controllers.md +203 -0
- package/docs/zh/03-middleware.md +220 -0
- package/docs/zh/04-rendering-and-hydration.md +271 -0
- package/docs/zh/05-i18n.md +243 -0
- package/docs/zh/06-http-client.md +286 -0
- package/docs/zh/07-di-container.md +264 -0
- package/docs/zh/08-observability.md +287 -0
- package/docs/zh/09-server-and-deployment.md +242 -0
- package/docs/zh/10-features-platform-pwa.md +238 -0
- package/docs/zh/README.md +72 -0
- package/docs/zh/advanced/custom-action-handler.md +248 -0
- package/docs/zh/advanced/custom-adapter.md +264 -0
- package/docs/zh/advanced/custom-event-recorder.md +318 -0
- package/docs/zh/advanced/inline-proxy-codegen.md +200 -0
- package/docs/zh/advanced/multi-tenant-scopes.md +330 -0
- package/docs/zh/engineering/ci-release-flow.md +244 -0
- package/docs/zh/engineering/project-structure.md +296 -0
- package/docs/zh/engineering/testing.md +317 -0
- package/docs/zh/pitfalls/container-scope-leak.md +215 -0
- package/docs/zh/pitfalls/i18n-bundle-size.md +182 -0
- package/docs/zh/pitfalls/proxy-binary-payloads.md +133 -0
- package/docs/zh/pitfalls/redirect-vs-rewrite.md +147 -0
- package/docs/zh/pitfalls/ssr-hydration-mismatch.md +163 -0
- package/docs/zh/pitfalls/ssr-vs-csr-globals.md +176 -0
- package/package.json +2 -1
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# 10. Features, platform, PWA
|
|
2
|
+
|
|
3
|
+
Three small, independent runtime helpers:
|
|
4
|
+
|
|
5
|
+
- **Feature flags** — config that can change without redeploy
|
|
6
|
+
- **Platform detection** — user-agent parsing for OS / browser / engine
|
|
7
|
+
- **PWA mode** — detect whether the app is installed (standalone)
|
|
8
|
+
|
|
9
|
+
Each is replaceable, each composable with custom providers.
|
|
10
|
+
|
|
11
|
+
## Feature flags
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
const framework = Framework.create({
|
|
15
|
+
featureFlags: {
|
|
16
|
+
darkMode: true,
|
|
17
|
+
maxRetries: 3,
|
|
18
|
+
experimentalCheckout: false,
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const flags = framework.container.resolve(DEP_KEYS.FEATURE_FLAGS);
|
|
23
|
+
flags.get("darkMode"); // true
|
|
24
|
+
flags.get("maxRetries"); // 3
|
|
25
|
+
flags.get("missing"); // undefined
|
|
26
|
+
flags.get("missing", "fallback"); // "fallback"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Flags can be any JSON-serializable value: booleans, strings, numbers, arrays, objects.
|
|
30
|
+
|
|
31
|
+
### Static config
|
|
32
|
+
|
|
33
|
+
The simplest case — flags shipped with the bundle:
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
Framework.create({
|
|
37
|
+
featureFlags: {
|
|
38
|
+
darkMode: process.env.NODE_ENV !== "production",
|
|
39
|
+
analytics: true,
|
|
40
|
+
cdnUrl: "https://cdn.example.com",
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Use this for flags driven by environment, not user attributes.
|
|
46
|
+
|
|
47
|
+
### Remote providers
|
|
48
|
+
|
|
49
|
+
Plug in a provider that fetches from a remote service (LaunchDarkly, GrowthBook, Unleash, your own config service):
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
import { type FeatureFlagsProvider } from "@finesoft/front";
|
|
53
|
+
|
|
54
|
+
const remoteConfigProvider: FeatureFlagsProvider = {
|
|
55
|
+
async load() {
|
|
56
|
+
const resp = await fetch("https://config.example.com/flags");
|
|
57
|
+
return resp.json(); // { ...flags }
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const framework = Framework.create({
|
|
62
|
+
featureFlags: {
|
|
63
|
+
darkMode: false,
|
|
64
|
+
maxRetries: 3,
|
|
65
|
+
},
|
|
66
|
+
featureFlagsProviders: [remoteConfigProvider],
|
|
67
|
+
});
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Providers run in registration order. Later providers override earlier values for the same key — "last registered wins."
|
|
71
|
+
|
|
72
|
+
### Cache lifecycle
|
|
73
|
+
|
|
74
|
+
The framework loads provider values once during `Framework.create()`. After that, flags are read synchronously from memory.
|
|
75
|
+
|
|
76
|
+
To refresh, call `flags.refresh()`:
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
const flags = framework.container.resolve(DEP_KEYS.FEATURE_FLAGS);
|
|
80
|
+
await flags.refresh(); // re-runs all providers
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
You'd typically call this on a timer or in response to a server-sent event.
|
|
84
|
+
|
|
85
|
+
### Targeting
|
|
86
|
+
|
|
87
|
+
Built-in flags are global (same value for every user). For per-user targeting, structure your provider to return a function or use a separate evaluation step:
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
class TargetingProvider implements FeatureFlagsProvider {
|
|
91
|
+
constructor(private userId: string) {}
|
|
92
|
+
async load() {
|
|
93
|
+
const resp = await fetch(`https://config.example.com/flags?userId=${this.userId}`);
|
|
94
|
+
return resp.json();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Register per-request in a beforeLoad guard:
|
|
99
|
+
async function flagsGuard(ctx) {
|
|
100
|
+
const userId = await getUserIdFromCookie(ctx);
|
|
101
|
+
const targeting = new TargetingProvider(userId);
|
|
102
|
+
const flags = await targeting.load();
|
|
103
|
+
ctx.container.register(DEP_KEYS.FEATURE_FLAGS, () => ({
|
|
104
|
+
get: (key, fallback) => flags[key] ?? fallback,
|
|
105
|
+
}));
|
|
106
|
+
return next();
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
For complex bucketing, hand the user id to a dedicated service (GrowthBook SDK, etc.) and store its evaluator in DI.
|
|
111
|
+
|
|
112
|
+
### SSR / CSR consistency
|
|
113
|
+
|
|
114
|
+
Flags evaluated on the server and not re-evaluated on the browser would cause hydration mismatch. The framework serializes flag values into `PrefetchedIntents` if a controller reads them. Browser-side reads return the same value the server saw.
|
|
115
|
+
|
|
116
|
+
For flags that _should_ differ (e.g., A/B variants), evaluate them in a `beforeLoad` guard and store the result in the request scope — both server and browser will use the value resolved by the server.
|
|
117
|
+
|
|
118
|
+
## Platform detection
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
import { detectPlatform } from "@finesoft/front";
|
|
122
|
+
|
|
123
|
+
const info = detectPlatform();
|
|
124
|
+
// {
|
|
125
|
+
// os: "ios" | "android" | "macos" | "windows" | "linux" | "other",
|
|
126
|
+
// browser: "safari" | "chrome" | "firefox" | "edge" | ...,
|
|
127
|
+
// engine: "webkit" | "blink" | "gecko" | "other",
|
|
128
|
+
// isMobile: boolean,
|
|
129
|
+
// isTouch: boolean,
|
|
130
|
+
// isServer: boolean,
|
|
131
|
+
// }
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
In the browser, `detectPlatform()` reads `navigator.userAgent`. On the server, parsing the request's `User-Agent` header is automatic via the framework:
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
const platform = framework.getPlatform();
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
For controllers and guards, resolve from DI:
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
const platform = ctx.container.resolve(DEP_KEYS.PLATFORM);
|
|
144
|
+
if (platform.isMobile) {
|
|
145
|
+
return rewrite("/m" + ctx.url.pathname);
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Reliability
|
|
150
|
+
|
|
151
|
+
User-Agent strings lie — every modern browser embeds substrings of every other browser for compatibility. The framework's detection prioritizes well-known patterns and falls back to `"other"` on ambiguity. Don't make critical decisions on `browser` alone:
|
|
152
|
+
|
|
153
|
+
- ✅ Adjust layout for `isMobile`
|
|
154
|
+
- ✅ Hide Safari-only features for non-WebKit
|
|
155
|
+
- ❌ Lock specific browsers out
|
|
156
|
+
- ❌ Choose code paths based on browser version
|
|
157
|
+
|
|
158
|
+
## PWA detection
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
import { getPWADisplayMode } from "@finesoft/front";
|
|
162
|
+
|
|
163
|
+
const mode = getPWADisplayMode();
|
|
164
|
+
// "standalone" | "twa" | "browser"
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
- `"standalone"` — running as an installed PWA (Safari add-to-home, Chrome install)
|
|
168
|
+
- `"twa"` — Trusted Web Activity (Android, wrapped as a native app)
|
|
169
|
+
- `"browser"` — regular browser tab
|
|
170
|
+
|
|
171
|
+
The function reads `window.matchMedia("(display-mode: standalone)")` and Android's TWA referrer. Server-side: returns `"browser"`.
|
|
172
|
+
|
|
173
|
+
### Common uses
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
const mode = getPWADisplayMode();
|
|
177
|
+
|
|
178
|
+
if (mode === "browser") {
|
|
179
|
+
showInstallBanner();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (mode === "standalone") {
|
|
183
|
+
// Customize navigation — installed app shouldn't show "Install" prompt
|
|
184
|
+
hideInstallButton();
|
|
185
|
+
enableNativeBackButtonHandling();
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### Service worker registration
|
|
190
|
+
|
|
191
|
+
PWA install is independent of service workers — you can have one without the other. To register a service worker:
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
// src/main.ts
|
|
195
|
+
startBrowserApp({
|
|
196
|
+
bootstrap,
|
|
197
|
+
mount,
|
|
198
|
+
onAfterStart() {
|
|
199
|
+
if ("serviceWorker" in navigator) {
|
|
200
|
+
navigator.serviceWorker.register("/sw.js");
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
The framework does not ship a service worker generator. Use [Vite PWA](https://vite-pwa-org.netlify.app/) or hand-roll one.
|
|
207
|
+
|
|
208
|
+
## Composing them
|
|
209
|
+
|
|
210
|
+
A common navigation guard combining all three:
|
|
211
|
+
|
|
212
|
+
```ts
|
|
213
|
+
import { next, rewrite, DEP_KEYS } from "@finesoft/front";
|
|
214
|
+
|
|
215
|
+
function mobilePwaGuard(ctx) {
|
|
216
|
+
const platform = ctx.container.resolve(DEP_KEYS.PLATFORM);
|
|
217
|
+
const flags = ctx.container.resolve(DEP_KEYS.FEATURE_FLAGS);
|
|
218
|
+
|
|
219
|
+
if (flags.get("mobilePwaRedesign") && platform.isMobile && !ctx.isSsr) {
|
|
220
|
+
if (getPWADisplayMode() === "standalone") {
|
|
221
|
+
return rewrite(`/pwa${ctx.url.pathname}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return next();
|
|
225
|
+
}
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
This routes installed mobile PWA users to a different page tree without affecting other users.
|
|
229
|
+
|
|
230
|
+
## Caveats
|
|
231
|
+
|
|
232
|
+
- **Feature flags resolved server-side ship in HTML.** Don't store secrets in flags.
|
|
233
|
+
- **Platform detection on the server uses request headers.** A bot or curl might not send a useful User-Agent — handle `"other"` gracefully.
|
|
234
|
+
- **PWA detection on the server always returns `"browser"`.** Don't rely on it in SSR rendering paths; conditional UI based on PWA mode should be client-only or use `<noscript>` fallbacks.
|
|
235
|
+
|
|
236
|
+
## Next
|
|
237
|
+
|
|
238
|
+
- [Engineering: project structure](./engineering/project-structure.md) — where to put flag config, platform-aware code
|
package/docs/README.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# `@finesoft/front` documentation
|
|
2
|
+
|
|
3
|
+
> **Language:** English (this page) · **[简体中文](./zh/README.md)**
|
|
4
|
+
|
|
5
|
+
Full-stack TypeScript framework — router, DI, actions, SSR, and server — in one package. Works with **Vue**, **React**, or **Svelte**. Deploys to Node.js, Vercel, Cloudflare Workers, Netlify, or static hosting.
|
|
6
|
+
|
|
7
|
+
## Three entry points
|
|
8
|
+
|
|
9
|
+
Pick where to start based on what you need.
|
|
10
|
+
|
|
11
|
+
### New to the framework — read top to bottom
|
|
12
|
+
|
|
13
|
+
A linear path. Each chapter assumes the previous one. By the end you can build, render, and deploy a real app.
|
|
14
|
+
|
|
15
|
+
1. [Getting started](./01-getting-started.md) — install, Vite config, first page
|
|
16
|
+
2. [Routing & controllers](./02-routing-and-controllers.md) — route definitions, intents, controllers, render modes
|
|
17
|
+
3. [Middleware](./03-middleware.md) — `beforeLoad` / `afterLoad`, redirect / rewrite / deny
|
|
18
|
+
4. [Rendering & hydration](./04-rendering-and-hydration.md) — SSR / CSR / Prerender, `PrefetchedIntents`
|
|
19
|
+
5. [Internationalization](./05-i18n.md) — `locale`, `Translator`, dictionary loading, RTL
|
|
20
|
+
6. [HTTP client](./06-http-client.md) — `HttpClient` subclassing, interceptors, `HttpError`
|
|
21
|
+
7. [DI container](./07-di-container.md) — registration, scopes, `DEP_KEYS`, dispose
|
|
22
|
+
8. [Observability](./08-observability.md) — `Logger`, `EventRecorder`, impression tracking, `ReportCallback`
|
|
23
|
+
9. [Server & deployment](./09-server-and-deployment.md) — `createServer`, proxy, adapters, Vite plugin
|
|
24
|
+
10. [Features, platform, PWA](./10-features-platform-pwa.md) — feature flags, platform detection, PWA mode
|
|
25
|
+
|
|
26
|
+
### Engineer working on an existing app — jump to practice
|
|
27
|
+
|
|
28
|
+
Cross-cutting concerns and conventions. Read after you understand the basics.
|
|
29
|
+
|
|
30
|
+
- [Project structure](./engineering/project-structure.md) — recommended layout, `bootstrap.ts` splitting, single source of truth
|
|
31
|
+
- [Testing](./engineering/testing.md) — controllers, middleware, scoped DI, mocking the framework
|
|
32
|
+
- [CI & release flow](./engineering/ci-release-flow.md) — changesets, the bundled release workflow, version reconciliation
|
|
33
|
+
|
|
34
|
+
### Hit a problem — go to pitfalls
|
|
35
|
+
|
|
36
|
+
Each entry is **symptom → root cause → fix**, kept short.
|
|
37
|
+
|
|
38
|
+
- [SSR hydration mismatch](./pitfalls/ssr-hydration-mismatch.md)
|
|
39
|
+
- [SSR vs CSR globals](./pitfalls/ssr-vs-csr-globals.md)
|
|
40
|
+
- [Redirect vs rewrite](./pitfalls/redirect-vs-rewrite.md)
|
|
41
|
+
- [Proxy binary payloads](./pitfalls/proxy-binary-payloads.md)
|
|
42
|
+
- [Container scope leak](./pitfalls/container-scope-leak.md)
|
|
43
|
+
- [i18n bundle size](./pitfalls/i18n-bundle-size.md)
|
|
44
|
+
|
|
45
|
+
### Extending the framework — advanced recipes
|
|
46
|
+
|
|
47
|
+
Each recipe is a complete, runnable extension example with explanation.
|
|
48
|
+
|
|
49
|
+
- [Custom action handler](./advanced/custom-action-handler.md) — beyond `FlowAction` / `ExternalUrlAction`
|
|
50
|
+
- [Custom event recorder](./advanced/custom-event-recorder.md) — wire Sentry / Datadog / your own pipeline
|
|
51
|
+
- [Custom adapter](./advanced/custom-adapter.md) — target a new platform
|
|
52
|
+
- [Inline proxy codegen](./advanced/inline-proxy-codegen.md) — generate self-contained proxy routes for serverless / edge
|
|
53
|
+
- [Multi-tenant scopes](./advanced/multi-tenant-scopes.md) — per-tenant DI containers
|
|
54
|
+
|
|
55
|
+
## At-a-glance
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
URL/Action → Router.resolve()
|
|
59
|
+
→ beforeLoad chain (NavigationContext: redirect/rewrite/deny/next)
|
|
60
|
+
→ IntentDispatcher (controller.execute() → Page; fallback() on error)
|
|
61
|
+
→ afterLoad chain (PostLoadContext)
|
|
62
|
+
→ render (SSR: HTML + serialized PrefetchedIntents; CSR: shell)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The same `bootstrap()` runs on the server and in the browser. SSR serializes prefetched intent results into HTML; the browser deserializes them into `PrefetchedIntents` so the first client navigation reuses server results without a refetch.
|
|
66
|
+
|
|
67
|
+
## Conventions used in these docs
|
|
68
|
+
|
|
69
|
+
- **Code blocks** are runnable as written unless a comment says otherwise.
|
|
70
|
+
- **File paths** are relative to the project root (the directory containing `vite.config.ts`).
|
|
71
|
+
- **`vp`** is the [Vite+](https://github.com/voidzero-dev/setup-vp) CLI. Use it instead of calling `pnpm` / `npm` / `vitest` / `tsdown` directly.
|
|
72
|
+
- **`@finesoft/front`** is the only import surface for application code. Internal packages (`core`, `browser`, `ssr`, `server`) are bundled in and not published.
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# Advanced: custom action handler
|
|
2
|
+
|
|
3
|
+
The framework ships three action kinds: `flow` (in-app navigation), `external-url` (full browser navigation), and `compound` (a tuple of actions executed in order). For most apps these are enough.
|
|
4
|
+
|
|
5
|
+
This recipe shows how to add your own — useful when you have a class of operations that need cross-cutting handling (analytics, confirmations, telemetry) without polluting every callsite.
|
|
6
|
+
|
|
7
|
+
## Use case: confirmation-gated action
|
|
8
|
+
|
|
9
|
+
We'll add a `"confirm"` action kind: dispatch it with `{ kind: "confirm", message, then }`, and the framework shows a confirmation dialog before dispatching `then` (which is itself an action).
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
dispatch({
|
|
13
|
+
kind: "confirm",
|
|
14
|
+
message: "Delete this item permanently?",
|
|
15
|
+
then: { kind: "flow", url: "/items/42/deleted" },
|
|
16
|
+
});
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The user clicks Cancel → no navigation. Clicks OK → the inner flow action fires.
|
|
20
|
+
|
|
21
|
+
## Step 1: define the action type
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
// src/lib/actions/confirm.ts
|
|
25
|
+
import { type Action } from "@finesoft/front";
|
|
26
|
+
|
|
27
|
+
export interface ConfirmAction {
|
|
28
|
+
kind: "confirm";
|
|
29
|
+
message: string;
|
|
30
|
+
then: Action;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function makeConfirmAction(message: string, then: Action): ConfirmAction {
|
|
34
|
+
return { kind: "confirm", message, then };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function isConfirmAction(action: Action): action is ConfirmAction {
|
|
38
|
+
return (action as any).kind === "confirm";
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The shape is yours — `kind` just has to be unique among registered handlers.
|
|
43
|
+
|
|
44
|
+
## Step 2: extend the `Action` type union
|
|
45
|
+
|
|
46
|
+
TypeScript doesn't auto-expand the framework's `Action` type. Declare a module augmentation:
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
// src/lib/actions/confirm.ts
|
|
50
|
+
declare module "@finesoft/front" {
|
|
51
|
+
interface ActionRegistry {
|
|
52
|
+
confirm: ConfirmAction;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
If the framework exposes `ActionRegistry` (most pluggable frameworks do), this lets TypeScript know about your new kind. If it doesn't, cast at registration time:
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
framework.actionDispatcher.register("confirm" as any, handleConfirm as any);
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The runtime doesn't care — `kind` is a plain string at dispatch time.
|
|
64
|
+
|
|
65
|
+
## Step 3: write the handler
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
// src/lib/actions/confirm.ts
|
|
69
|
+
import type { Framework } from "@finesoft/front";
|
|
70
|
+
|
|
71
|
+
export function registerConfirmHandler(framework: Framework): void {
|
|
72
|
+
framework.actionDispatcher.register("confirm", async (action: ConfirmAction) => {
|
|
73
|
+
if (typeof window === "undefined") {
|
|
74
|
+
// SSR: confirmation isn't possible — fall through to the inner action
|
|
75
|
+
await framework.actionDispatcher.dispatch(action.then);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const confirmed = window.confirm(action.message);
|
|
80
|
+
if (!confirmed) return;
|
|
81
|
+
|
|
82
|
+
await framework.actionDispatcher.dispatch(action.then);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Key points:
|
|
88
|
+
|
|
89
|
+
- The handler runs on both server and client. On the server `window` doesn't exist — decide what "no UI" means for your action.
|
|
90
|
+
- Recursive dispatch (`actionDispatcher.dispatch(action.then)`) goes through the regular pipeline, including any other custom handlers.
|
|
91
|
+
- The framework already protects compound actions with a recursion-depth limit (default 4). Your handler is reached via dispatch, so it inherits that limit.
|
|
92
|
+
|
|
93
|
+
## Step 4: register at app startup
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
// src/main.ts
|
|
97
|
+
import { startBrowserApp } from "@finesoft/front/browser";
|
|
98
|
+
import { bootstrap } from "./bootstrap";
|
|
99
|
+
import { registerConfirmHandler } from "./lib/actions/confirm";
|
|
100
|
+
|
|
101
|
+
startBrowserApp({
|
|
102
|
+
bootstrap,
|
|
103
|
+
onBeforeStart(framework) {
|
|
104
|
+
registerConfirmHandler(framework);
|
|
105
|
+
},
|
|
106
|
+
mount: /* ... */,
|
|
107
|
+
});
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Mirror on the SSR side:
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
// src/ssr.ts
|
|
114
|
+
export const render = createSSRRender({
|
|
115
|
+
bootstrap,
|
|
116
|
+
onBeforeStart(framework) {
|
|
117
|
+
registerConfirmHandler(framework);
|
|
118
|
+
},
|
|
119
|
+
async renderApp(page) {
|
|
120
|
+
/* ... */
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Or, simpler: register inside `bootstrap()` so both sides get it automatically.
|
|
126
|
+
|
|
127
|
+
## Step 5: use it
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
// In a view component
|
|
131
|
+
import { makeConfirmAction, makeFlowAction } from "@finesoft/front";
|
|
132
|
+
|
|
133
|
+
function onDelete(id: string) {
|
|
134
|
+
framework.actionDispatcher.dispatch(
|
|
135
|
+
makeConfirmAction(`Delete item ${id}?`, makeFlowAction(`/items/${id}/deleted`)),
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Replacing an existing handler
|
|
141
|
+
|
|
142
|
+
Each `kind` can be registered exactly once. The dispatcher warns on duplicate registrations and skips:
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
framework.actionDispatcher.register("flow", myFlowHandler);
|
|
146
|
+
// [ActionDispatcher] kind="flow" already registered, skipping
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
To replace, unregister first:
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
framework.actionDispatcher.removeAction("flow");
|
|
153
|
+
framework.actionDispatcher.register("flow", myFlowHandler);
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Useful when you want to wrap the default flow handler with logging or analytics:
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
import { registerFlowActionHandler, type FlowActionDependencies } from "@finesoft/front";
|
|
160
|
+
|
|
161
|
+
const baseHandler = framework.actionDispatcher.getHandler("flow"); // hypothetical
|
|
162
|
+
framework.actionDispatcher.removeAction("flow");
|
|
163
|
+
framework.actionDispatcher.register("flow", async (action) => {
|
|
164
|
+
console.log("[nav]", action.url);
|
|
165
|
+
await baseHandler(action);
|
|
166
|
+
});
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
In practice, prefer middleware (`beforeLoad`) for cross-cutting concerns on navigation — replacing the flow handler is invasive.
|
|
170
|
+
|
|
171
|
+
## Compound actions with custom kinds
|
|
172
|
+
|
|
173
|
+
`CompoundAction` works with any registered kind:
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
framework.actionDispatcher.dispatch({
|
|
177
|
+
kind: "compound",
|
|
178
|
+
actions: [
|
|
179
|
+
makeFlowAction("/checkout/complete"),
|
|
180
|
+
makeConfirmAction("Add to email list?", { kind: "subscribe", email: user.email }),
|
|
181
|
+
],
|
|
182
|
+
});
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Each inner action runs sequentially. A handler that throws short-circuits the remaining actions in the compound — wrap with `try/catch` if you want best-effort semantics.
|
|
186
|
+
|
|
187
|
+
## Server-side considerations
|
|
188
|
+
|
|
189
|
+
Action handlers run on the server during SSR if the controller dispatches them. Common patterns:
|
|
190
|
+
|
|
191
|
+
- **External URLs**: the server can't navigate the user — most apps return early. The framework's built-in `external-url` handler does exactly that on SSR.
|
|
192
|
+
- **Confirm-style**: no user to ask. Either auto-accept (use the inner action) or auto-reject (drop it).
|
|
193
|
+
- **Telemetry-only**: works the same on both sides. Just record.
|
|
194
|
+
|
|
195
|
+
If your handler depends on browser APIs that don't exist on the server, gate with `typeof window === "undefined"`.
|
|
196
|
+
|
|
197
|
+
## Testing
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
import { afterEach, describe, expect, test, vi } from "vite-plus/test";
|
|
201
|
+
import { Framework } from "@finesoft/front";
|
|
202
|
+
import { registerConfirmHandler, makeConfirmAction } from "./confirm";
|
|
203
|
+
|
|
204
|
+
describe("confirm action", () => {
|
|
205
|
+
afterEach(() => vi.restoreAllMocks());
|
|
206
|
+
|
|
207
|
+
test("dispatches inner action when confirmed", async () => {
|
|
208
|
+
const framework = Framework.create({});
|
|
209
|
+
registerConfirmHandler(framework);
|
|
210
|
+
vi.stubGlobal("window", { confirm: () => true });
|
|
211
|
+
|
|
212
|
+
const innerHandler = vi.fn();
|
|
213
|
+
framework.actionDispatcher.register("test", innerHandler);
|
|
214
|
+
|
|
215
|
+
await framework.actionDispatcher.dispatch(
|
|
216
|
+
makeConfirmAction("ok?", { kind: "test" } as any),
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
expect(innerHandler).toHaveBeenCalled();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("skips inner action when cancelled", async () => {
|
|
223
|
+
const framework = Framework.create({});
|
|
224
|
+
registerConfirmHandler(framework);
|
|
225
|
+
vi.stubGlobal("window", { confirm: () => false });
|
|
226
|
+
|
|
227
|
+
const innerHandler = vi.fn();
|
|
228
|
+
framework.actionDispatcher.register("test", innerHandler);
|
|
229
|
+
|
|
230
|
+
await framework.actionDispatcher.dispatch(
|
|
231
|
+
makeConfirmAction("ok?", { kind: "test" } as any),
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
expect(innerHandler).not.toHaveBeenCalled();
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
## When to use a custom action vs middleware
|
|
240
|
+
|
|
241
|
+
| Concern | Custom action | Middleware (`beforeLoad`) |
|
|
242
|
+
| ----------------------------------------------- | ------------- | ---------------------------- |
|
|
243
|
+
| Confirmation before navigating to specific URLs | ✅ | ❌ (would run for every nav) |
|
|
244
|
+
| Audit log on every navigation | ❌ | ✅ |
|
|
245
|
+
| New mechanism for performing an operation | ✅ | ❌ |
|
|
246
|
+
| Gate-keeping all navigation to admin routes | ❌ | ✅ |
|
|
247
|
+
|
|
248
|
+
Custom actions are for **new kinds of operations.** Middleware is for **cross-cutting concerns on existing operations.**
|