@stealthscale/sdk-host 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Stealth Scale B.V.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,257 @@
1
+ # @stealthscale/sdk-host
2
+
3
+ `@stealthscale/sdk-host` runs a product composed from plugins. It keeps the state the plugins read
4
+ through `@stealthscale/sdk-plugin` and compiles their routes. It renders the frame's regions, the
5
+ command palette, the toasts, the not-found page and the settings pages.
6
+
7
+ A product creates one host per page in the browser and one per request on a server.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pnpm add @stealthscale/sdk-host
13
+ ```
14
+
15
+ The package peers on `react`, `react-dom`, `@tanstack/react-router-ssr-query`,
16
+ `@stealthscale/sdk-plugin`, the providers it reads and the component packages it renders:
17
+
18
+ - `provider-data`, `provider-router`, `provider-i18n`, `provider-hotkeys`, `provider-form`,
19
+ `settings` and `hooks`
20
+ - `component-actions`, `component-feedback`, `component-forms`, `component-layout`,
21
+ `component-modals`, `component-navigation`, `component-screen` and `component-typography`
22
+
23
+ The `./openfeature` entry peers on `@openfeature/web-sdk` as well, which is optional. The
24
+ `./standalone/app` entry peers on `provider-color-mode`, `provider-locale` and `provider-shell`,
25
+ which are optional for the other entries. The host's words are in the `host` catalogue namespace,
26
+ `locales/<language>/host.json`.
27
+
28
+ ## Compose the application
29
+
30
+ ```tsx
31
+ import { product } from "virtual:product";
32
+
33
+ const routes = createHostRoutes(product);
34
+ const root = createAppRootRoute<HostRouterContext>()({
35
+ component: () => (
36
+ <HostRoot>
37
+ <Frame />
38
+ </HostRoot>
39
+ ),
40
+ notFoundComponent: HostNotFound,
41
+ });
42
+ const tree = root.addChildren([...routes(root)]);
43
+
44
+ const host = createHost({
45
+ data: { changes: changesSubscription, transport: gatewayTransport(gateway) },
46
+ flags: openFeatureFlags(),
47
+ product,
48
+ session: identitySession,
49
+ store: localStore(),
50
+ });
51
+ const router = createRouter({
52
+ ...routerOptions({ data: host.data, host, routes: routeMap(tree) }),
53
+ routeTree: tree,
54
+ });
55
+
56
+ await host.ready();
57
+
58
+ createRoot(element).render(
59
+ <Shell app={product.productId} catalogues={catalogues}>
60
+ <HostProvider host={host} router={router}>
61
+ <RouterProvider router={router} />
62
+ </HostProvider>
63
+ </Shell>,
64
+ );
65
+ ```
66
+
67
+ | Piece | Created | Renders or keeps |
68
+ | ---------------------- | --------------------------------- | -------------------------------------------------------------------------------------- |
69
+ | `createHostRoutes` | Once per route tree | The plugins' routes, the settings route, its index and a route per settings page |
70
+ | `createHost` | Once per page, once per request | The stores, the event bus, the data client, the toaster, the quarantine, the reports |
71
+ | `HostRoot` | The root route's component | The `layout` region around the frame, the `overlay` region, the palette and the toasts |
72
+ | `HostNotFound` | The root's not-found component | The not-found page, with the reason a plugin's page is not found |
73
+ | `HostProvider` | Once per render tree | The host's context, `DataProvider` and the `root` region around the product's tree |
74
+ | `HostContent` | In the frame, where the page goes | The `content` region around the matched page |
75
+ | `setupHostIntegration` | Once per router | The host's state in a server render, and the browser's host started from it |
76
+
77
+ - Build the tree once. A plugin that turns off makes its routes not found, and the router keeps the
78
+ tree it was built from.
79
+ - `routerOptions` puts the host, its data client and the route map in the router's context, which
80
+ `HostRouterContext` types. Every route's condition reads the host from there, so one tree serves
81
+ every request on a server.
82
+ - `host.ready()` resolves once the eager plugins' modules are imported and the flag source has
83
+ identified the session, or once `flagsTimeout` passes.
84
+
85
+ ## Create a host
86
+
87
+ | Option | Where left out | States |
88
+ | ----------------- | ------------------------------------------------- | ---------------------------------------------------------------------------- |
89
+ | `product` | Required | The resolved product with its plugins' manifests, from `virtual:product` |
90
+ | `session` | Required | The `SessionSource` the session comes from |
91
+ | `store` | Required | The `SettingStore` a person's switches, settings and placements are kept in |
92
+ | `access` | Every decision is the tenant-wide one | The `AccessSource` of decisions on single resources |
93
+ | `data` | A transport over the contracts' samples | The transport and the changes subscription the data client runs on |
94
+ | `flags` | Every flag has the product's value or its default | The `FlagSource` of flag values per session |
95
+ | `flagsTimeout` | 1,000 ms | How long `ready` waits for the flag source's first `identify` |
96
+ | `glyphs` | No glyphs | The glyphs the settings sections' forms render, such as a checked box's mark |
97
+ | `overrides` | On outside a production build | Whether the tab's flag overrides are read and written |
98
+ | `quarantineAfter` | 3 | Failed renders in a row after which a route or an extension is quarantined |
99
+ | `report` | The console | The function that receives every `HostReport` entry |
100
+
101
+ - `createHost` throws where a manifest lacks code for a declared name, and names the plugin and the
102
+ name.
103
+ - The console report writes an entry that has an error with `console.error`, and every other entry
104
+ with `console.warn`, each prefixed `[host]`.
105
+ - The data client refuses an operation no installed plugin declares. Each arrival of a declared
106
+ query's data primes the access decisions its selectors state.
107
+ - `retry(target)` lifts a quarantine. `dispose()` ends the host's subscriptions and cancels the data
108
+ client's fetches.
109
+
110
+ ## Routes
111
+
112
+ `createHostRoutes(product)` returns a function of the root route. The function compiles every route
113
+ in one `compileRoutes` call:
114
+
115
+ - A plugin route loads its page with the modules of the plugins in its `loads`, loads its declared
116
+ data through the data client, and validates its search with its contract's validator.
117
+ - Its condition is evaluated against the host in the router's context. A route whose plugin is off,
118
+ stopped by its kill switch or quarantined is not found, with `NotFoundData` that states the
119
+ reason: `PluginUnavailable` or `PageQuarantined`.
120
+ - Where a condition that requires a signed-in person is false for a person who is not signed in, the
121
+ route redirects to the product's sign-in route, with the entered address as the `redirect` search.
122
+ - A page that throws renders its manifest's fallback, or the host's error page. Each error counts
123
+ once towards the route's quarantine.
124
+
125
+ `HostNotFound` renders the not-found page. For a plugin a switch turned off it offers a button that
126
+ turns the plugin on, and for a quarantined page a retry. Where the page replaced the page a person
127
+ was on, focus moves to its heading.
128
+
129
+ ## The frame
130
+
131
+ - `HostProvider` renders the host's context, `DataProvider` and the `root` region around the
132
+ product's tree. It invalidates the router after a change a route condition reads, emits
133
+ `host/navigated`, and titles the document with the product's name.
134
+ - `HostRoot` renders the frame inside the `layout` region, then the `overlay` region with the
135
+ command palette and the toast region, and binds the commands' keys. `Mod+K` opens the palette.
136
+ - `HostContent` renders the matched page inside the `content` region. Render it in the frame where
137
+ the page belongs.
138
+ - `HostProvider` and `HostRoot` each render a last boundary. An error outside every plugin renders a
139
+ page that offers a reload, and is reported as `render-failed` with the target `host`.
140
+
141
+ ## Settings
142
+
143
+ The settings route, `host/settings`, renders a page with the settings menu beside the settings page
144
+ a person is on. Its index opens the menu's first entry.
145
+
146
+ - A settings page renders the sections that target it, each inside its own boundary. A schema
147
+ section renders a form from its schema and saves its values through `useSettings`.
148
+ - The Plugins page lists every installed plugin with a switch where the plugin is switchable. It
149
+ asks before it turns off a plugin that other plugins require.
150
+ - The account page renders where an installed section targets it.
151
+
152
+ Pass `glyphs` to `createHost`, or the sections' forms render no marks.
153
+
154
+ ## A chunk that no longer exists
155
+
156
+ A page loaded before a deployment requests chunks that the deployment replaced. The host reloads the
157
+ page once per build version, the product's `version`:
158
+
159
+ - A plugin's module that fails to import starts the recovery, and so does Vite's `vite:preloadError`
160
+ while `HostProvider` is mounted. The import does not settle, so no error renders while the page
161
+ reloads.
162
+ - The version is recorded under `stealth.<productId>.reloaded` in session storage. A second failure
163
+ for the same version renders the error.
164
+ - A server, and a page whose storage the browser refuses, render the error without a reload.
165
+
166
+ Each plugin's first import is measured as `stealth:load:<plugin id>`.
167
+
168
+ ## Server rendering
169
+
170
+ Call `setupHostIntegration` where you call `setupDataIntegration`: for each request on a server, and
171
+ in the browser before the router hydrates.
172
+
173
+ ```ts
174
+ setupDataIntegration({ client: host.data, router });
175
+ setupHostIntegration({ host, router });
176
+ ```
177
+
178
+ - On a server the router's dehydrated state gains the host's snapshot: the subject, the flags the
179
+ render read and the decisions the host knew before the render.
180
+ - In the browser the router waits for the snapshot before its first render. The host reads the
181
+ server's flags until `HostProvider` mounts, and takes the decisions where the browser's subject is
182
+ the server's, so the first render matches the server's.
183
+ - `HostProvider`'s first effect applies the tab's overrides and the browser's flag source.
184
+
185
+ Create a host per request on a server, with `constantSession` and a setting store over the request's
186
+ cookies.
187
+
188
+ ## A plugin on its own
189
+
190
+ `@stealthscale/sdk-host/standalone` builds the product a plugin runs in on its own, for its tests
191
+ and its standalone page. The entry imports `@stealthscale/sdk-core` alone, so the build evaluates it
192
+ in Node.
193
+
194
+ | Export | Returns |
195
+ | -------------------------------- | --------------------------------------------------------------------------------------------- |
196
+ | `standaloneProduct(options)` | The definition that installs the plugin and each contract in `beside` from its contract alone |
197
+ | `standaloneFrom(plugin, beside)` | The same definition, from the plugin's manifest module and each contract package's module |
198
+ | `standaloneSources(product)` | The session, access and flag sources a panel or a test switches |
199
+
200
+ - The product's id is the plugin's, and its name is the plugin's `plugin.name`.
201
+ - A plugin installed from its contract alone renders a placeholder page per route. The page's
202
+ heading is the route's id, and the page renders each of the plugin's slots with its sample props.
203
+ Its extensions render the content they wrap, its commands resolve with nothing, and its settings
204
+ sections without a schema name the plugin.
205
+ - `standaloneFrom` throws unless the plugin's module exports exactly one manifest and each module
206
+ beside it exactly one contract. The error gives the module's path or package name.
207
+ - The session starts signed in, with every declared permission and entitlement. `set` replaces it.
208
+ - The access source allows each check on one resource whose permission the session has, until
209
+ `deny`, `pending` or `decide` switches the decision. It notifies on each switch and each session
210
+ change, so the host asks again.
211
+ - The flag source turns every boolean flag on and leaves every experiment at its default.
212
+
213
+ `renderStandalone(options)` from `@stealthscale/sdk-host/standalone/app` renders the standalone page
214
+ into the element with the id `root`, or `element`, and resolves with its React root. The layer of
215
+ `@stealthscale/vite-config-product` serves the page and calls it.
216
+
217
+ | Option | Where left out | States |
218
+ | ------------ | --------------------------- | --------------------------------------------------------- |
219
+ | `product` | Required | The standalone product, from `virtual:product` |
220
+ | `catalogues` | Every key renders as itself | The catalogues, from `virtual:i18n` |
221
+ | `glyphs` | No glyphs | The settings forms' glyphs and the panel's stage triggers |
222
+ | `locales` | American English alone | The locales the page offers, the first its fallback |
223
+ | `themes` | The application's one theme | The themes the page offers |
224
+
225
+ The page opens at the plugin's first menu entry, else at the first route a sample fills. It renders
226
+ a frame of every region the plugins fill and the development panel over it. The panel is a floating
227
+ panel at the bottom-end corner, with one group of controls each:
228
+
229
+ - Page: every route a sample fills, by its id.
230
+ - Session: signed in, and a switch per declared permission and entitlement.
231
+ - Checks on one resource: Allow, Deny and Pending.
232
+ - Feature flags: a switch per boolean flag and a picker per experiment, through `flags.override`.
233
+ - Plugin: the plugin's switch, and its kill switch.
234
+ - Operations: a picker per query and mutation: the sample, the sample after 2 seconds, or a refusal
235
+ of each kind a `DataError` states. A query's pick loads the page's data again.
236
+ - Display: the language, the theme and the color mode.
237
+
238
+ ## OpenFeature
239
+
240
+ `openFeatureFlags({ domain })` from `@stealthscale/sdk-host/openfeature` returns a flag source over
241
+ the OpenFeature client of a domain, `stealth.host` by default.
242
+
243
+ ```ts
244
+ import { OpenFeature } from "@openfeature/web-sdk";
245
+ import { openFeatureFlags } from "@stealthscale/sdk-host/openfeature";
246
+
247
+ await OpenFeature.setProviderAndWait("stealth.host", provider);
248
+
249
+ const host = createHost({ flags: openFeatureFlags(), product, session, store });
250
+ ```
251
+
252
+ - `identify` sets the domain's evaluation context from the session: the person as the targeting key,
253
+ the tenant, the roles and the entitlements.
254
+ - A flag the provider does not know, a flag it disabled, and every flag where no provider is bound
255
+ to the domain take the product's value or the flag's default.
256
+ - OpenFeature keeps one context per domain for the page, so a server passes a flag source per
257
+ request.