Skip to content

Commit 137a0fa

Browse files
committed
docs: Vite framework flavors guide + Octane flavor
Documents registerFrameworkFlavor and the server/client strategy contract of @nativescript/vite, with @nativescript-community/vite-octane as the worked example; links it from the Vite reference and the sidebar.
1 parent b335570 commit 137a0fa

3 files changed

Lines changed: 264 additions & 0 deletions

File tree

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
---
2+
title: Framework Flavors
3+
description: Ship Vite HMR support for your own framework as a package, with registerFrameworkFlavor, a server strategy and a device-side client strategy.
4+
contributors:
5+
- NathanWalker
6+
---
7+
8+
`@nativescript/vite` has built-in flavors for Angular, Vue, React, Solid, TypeScript and JavaScript. A **flavor** is what turns a saved file into a change on the device: a config helper that declares it, a server strategy that runs in the Vite process, and a client strategy the device evaluates next to the shared HMR client.
9+
10+
Flavors are not limited to the built-ins. A framework — or a renderer, a router, anything that holds live objects between saves — can register a flavor from its own package, under its own npm scope, with no change to `@nativescript/vite`. This page is how. The worked example throughout is [`@nativescript-community/vite-octane`](https://github.com/nativescript-community/octane/tree/main/packages/vite-octane), the Octane flavor: a community package built entirely on this API.
11+
12+
## What a flavor provides
13+
14+
```ts
15+
import { registerFrameworkFlavor } from '@nativescript/vite/framework';
16+
17+
registerFrameworkFlavor({
18+
flavor: 'octane', // the name, everywhere
19+
server: octaneServerStrategy, // runs in the dev server
20+
client: '@nativescript-community/vite-octane/client', // fetched and evaluated by the device
21+
});
22+
```
23+
24+
| Part | Runs where | Responsibility |
25+
| --- | --- | --- |
26+
| **Config helper** | Vite process | Registers the flavor, wraps `baseConfig({ mode, flavor })`, adds the framework's Vite plugin(s). |
27+
| **Server strategy** | Vite process | Owns `handleHotUpdate` for the flavor's files: update the module graph, purge transform caches, broadcast the delta. |
28+
| **Client strategy** | Device | Decides what a freshly re-imported module *means* for the live app: fire accept callbacks, propagate to importers, reload the graph. |
29+
30+
The shared pieces stay shared. `@nativescript/vite` serves the app over HTTP ESM, speaks the WebSocket protocol, evicts and re-imports modules through `ns:module`, drives the on-device overlay, and tracks workers. A strategy never re-implements those.
31+
32+
## Package layout
33+
34+
```
35+
@my-framework/nativescript-vite/
36+
├─ package.json exports ".", "./client"; declares nativescript.vite
37+
├─ src/index.ts config helper + registerFrameworkFlavor (Node)
38+
├─ src/server/strategy.ts (Node)
39+
└─ src/client/strategy.ts (device)
40+
```
41+
42+
Two rules shape the layout:
43+
44+
- **The server half and the client half are different programs.** The server entry may import anything Node can load. The client entry is served to the device raw, so it must be plain ESM with explicit `.js` extensions on relative imports, and it must reach the shared client only through `@nativescript/vite/hmr/client/framework.js`. Keep the two in separate files; the config entry must not import the client file.
45+
- **Declare the flavor in `package.json`** so `nativescript-vite init` and flavor detection recognise the package:
46+
47+
```json
48+
{
49+
"name": "@nativescript-community/vite-octane",
50+
"exports": {
51+
".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" },
52+
"./client": { "import": "./dist/client/strategy.js", "types": "./dist/client/strategy.d.ts" }
53+
},
54+
"nativescript": {
55+
"vite": {
56+
"flavor": "octane",
57+
"config": { "import": "octaneConfig", "from": "@nativescript-community/vite-octane" }
58+
}
59+
},
60+
"peerDependencies": { "@nativescript/vite": ">=8.0.0", "vite": "^8.0.0" }
61+
}
62+
```
63+
64+
With that in place, `npx nativescript-vite init` in an app that depends on the package generates:
65+
66+
```ts
67+
import { defineConfig } from 'vite';
68+
import { octaneConfig } from '@nativescript-community/vite-octane';
69+
70+
export default defineConfig(({ mode }) => octaneConfig({ mode }));
71+
```
72+
73+
## 1. The config helper
74+
75+
```ts
76+
// src/index.ts
77+
import { mergeConfig, type UserConfig } from 'vite';
78+
import { baseConfig, getTypeCheckPlugins, registerFrameworkFlavor, type TypeCheckControlOptions } from '@nativescript/vite/framework';
79+
import { octane, type OctanePluginOptions } from '@octanejs/vite-plugin';
80+
import { octaneServerStrategy } from './server/strategy.js';
81+
82+
registerFrameworkFlavor({ flavor: 'octane', server: octaneServerStrategy, client: '@nativescript-community/vite-octane/client' });
83+
84+
export interface OctaneConfigOptions extends TypeCheckControlOptions {
85+
octane?: OctanePluginOptions;
86+
}
87+
88+
export const octaneConfig = ({ mode }: { mode: string }, options: OctaneConfigOptions = {}): UserConfig =>
89+
mergeConfig(baseConfig({ mode, flavor: 'octane' }), {
90+
plugins: [...getTypeCheckPlugins('typescript', options.typeCheck), ...octane(options.octane)],
91+
});
92+
```
93+
94+
Register at module scope, before `baseConfig` can run. `baseConfig` installs the HMR plugins for the declared flavor and looks the server strategy up by name; it also seeds the device bundle with the client module's path (`__NS_CLIENT_STRATEGY_URL__`) so the on-device loader can fetch it without knowing your package.
95+
96+
`getTypeCheckPlugins` takes the *kind* of type-check, not the flavor name: `'typescript'` for a `.ts`/`.tsx` project, `'vue'` for `vue-tsc`.
97+
98+
## 2. The server strategy
99+
100+
Most frameworks need nothing new on the server. `typescriptServerStrategy` is the generic device-module pipeline — it serves app files over `/ns/m`, primes the module graph, and emits deltas. Spread it, rename the flavor, and write the one thing that differs: the hot-update tail.
101+
102+
```ts
103+
// src/server/strategy.ts
104+
import * as path from 'node:path';
105+
import { purgeTransformCachesForHotUpdate, runHotUpdatePrologue, typescriptServerStrategy, type FrameworkServerStrategy } from '@nativescript/vite/framework';
106+
107+
const SCRIPT_FILE_RE = /\.(?:[mc]?[jt]sx?)$/i;
108+
109+
export const octaneServerStrategy: FrameworkServerStrategy = {
110+
...typescriptServerStrategy,
111+
flavor: 'octane',
112+
deferDeltaBroadcast: true,
113+
async handleHotUpdate(ctx, deps) {
114+
const state = await runHotUpdatePrologue(ctx, deps);
115+
if (!state) return;
116+
const { root, metrics, emitSummary } = state;
117+
const { moduleGraph, verbose, sharedTransformRequest } = deps;
118+
const { file, server } = ctx;
119+
if (!SCRIPT_FILE_RE.test(file)) return emitSummary();
120+
metrics.tAfterFramework = Date.now();
121+
122+
const rel = '/' + path.posix.normalize(path.relative(root, file)).split(path.sep).join('/');
123+
const id = moduleGraph.normalizeGraphId(rel);
124+
if (!moduleGraph.get(id)) moduleGraph.upsert(rel, `/* hmr ${Date.now()} */`, [], { emitDeltaOnInsert: true });
125+
126+
// The device applies an edit by re-fetching the module. Purge BEFORE the
127+
// delta goes out, or the re-fetch is served from the previous save.
128+
purgeTransformCachesForHotUpdate({ file, server, sharedTransformRequest, verbose, label: 'octane' });
129+
const fresh = await sharedTransformRequest(rel, 30000);
130+
if (fresh?.code) {
131+
const mod = server.moduleGraph.getModuleById(file);
132+
const deps = mod ? Array.from(mod.importedModules).map((m) => (m.id || '').replace(/\?.*$/, '')).filter(Boolean) : moduleGraph.get(id)?.deps ?? [];
133+
moduleGraph.upsert(id, fresh.code, deps as string[], { broadcastDelta: false });
134+
}
135+
const gm = moduleGraph.get(id);
136+
if (gm) moduleGraph.emitDelta([gm], []);
137+
emitSummary();
138+
},
139+
};
140+
```
141+
142+
`deferDeltaBroadcast: true` is the contract that makes the purge-then-broadcast order hold: the shared prologue records the change but leaves the broadcast to you. Read the module's dependency edges *after* the re-transform — import analysis runs inside it — so a newly added import reaches the client graph on the save that introduced it.
143+
144+
Other optional members of `FrameworkServerStrategy` cover the less common needs: `transformNodeModule` (patch a vendor module before it is served), `rewriteServedModule`, `registerRoutes` (framework-owned dev endpoints), `importMapEntries`, `volatilePatterns`, `handleClientCustomEvent` (a `hot.send` from the device). The interface is documented inline in `hmr/server/framework-strategy.ts`.
145+
146+
## 3. The client strategy
147+
148+
This is where a framework's HMR semantics live. The shared queue does, for every delta: evict the changed modules from the runtime registry → re-import each one → call the strategy. Your strategy answers: what now?
149+
150+
```ts
151+
// src/client/strategy.ts — served to the device as-is
152+
import type { FrameworkClientStrategy } from '@nativescript/vite/hmr/client/framework.js';
153+
import { getNsHotRegistry, graph, setUpdateStage } from '@nativescript/vite/hmr/client/framework.js';
154+
155+
export const octaneClientStrategy: FrameworkClientStrategy = {
156+
flavor: 'octane',
157+
drivesQueueOverlayStages: true,
158+
install() {},
159+
beforeBatchEvict(drained) { /* snapshot accept callbacks, run dispose */ },
160+
afterModuleReimport(id, namespace) { /* fire accept with the fresh namespace */ },
161+
async refreshAfterBatch(drained, ctx) { /* propagate what nothing accepted; finish the overlay */ },
162+
};
163+
export default octaneClientStrategy;
164+
```
165+
166+
Export it as `default`, `clientStrategy`, or `<flavor>ClientStrategy`.
167+
168+
### Hooks, in the order they run
169+
170+
| Hook | When | Typical use |
171+
| --- | --- | --- |
172+
| `install()` | once, when the client resolves the strategy | dev shims, event listeners |
173+
| `shouldQueueReimport(id)` | per changed id, before anything is fetched | return `false` for a module that must not evaluate in this realm (a worker script, a type-only module) or that nothing can accept |
174+
| `applyUnqueuedChanges(ids)` | once, for the ids declined above | fire dependency acceptors; request a graph reload |
175+
| `beforeBatchEvict(drained)` | once, before eviction | the last moment the outgoing module instances are reachable: read their `hot.accept` callbacks, drain `hot.dispose` |
176+
| `afterModuleReimport(id, namespace)` | per re-imported module | invoke the captured accept callbacks with the fresh namespace |
177+
| `refreshAfterBatch(drained, ctx)` | once, after the drain | walk the reverse graph for modules nothing accepted; `ctx.setUpdateOverlayStage('complete', …)` |
178+
| `handleGraphResync(changedIds)` | a full graph with drifted hashes (dev-server restart) | return `true` after requesting one ordered reload instead of the piecemeal default |
179+
| `handleHotUpdateMessage(msg, ctx)` | any protocol message the shared dispatcher did not consume | framework-specific messages a server strategy broadcasts |
180+
181+
`ctx.graph` (and the `graph` export) is the live mirror of the server's module graph: `Map<id, { deps, hash }>`. It is what makes "who imports the changed module" answerable on the device.
182+
183+
### The hot registry
184+
185+
`getNsHotRegistry()` is the process-wide `import.meta.hot` implementation — every served app module gets a context from it, keyed by canonical id (`/src/app`, extensionless). The members a strategy uses:
186+
187+
| Member | Purpose |
188+
| --- | --- |
189+
| `getAcceptCallbacks(key)` | the self-accept callbacks registered by the key's **current** evaluation (a copy); non-empty = self-accepting boundary |
190+
| `getDepAcceptors(depKey)` / `acceptsDep(owner, dep)` | the dependency form — `hot.accept('./worker', cb)` — resolved relative to the owner; lets an update reach a module that never imported the changed file |
191+
| `runDispose(keys)` / `runPrune(keys)` | drain `hot.dispose` / `hot.prune` |
192+
| `hasDeclined(keys)` | any `hot.decline()` |
193+
| `createHotContext(id).data` | persists across re-evaluations of the same key |
194+
| `dispatchHotEvent(event, payload)` / `createHotContext(id).on(event, cb)` | custom events; the shared client emits `vite:beforeFullReload`, `ns:full-reload-complete`, `ns:full-reload-failed` |
195+
| `requestFullReload(reason)` | the in-process graph reload: drains dispose, evicts every app-owned module (never `@nativescript/core` or vendor), re-imports the entry |
196+
197+
One subtlety worth knowing before you write `afterModuleReimport`: the registry holds the callbacks of the **latest** evaluation (Vite's semantics). If your framework's accept callback closes over a module-local binding — as Octane's does — the callback that can reach the live instances is the **first** evaluation's, and you must keep firing that one. If it consults a global registry keyed by component id (Vue, React Refresh, solid-refresh), the latest is equivalent. The Octane strategy's `anchors` map is the reference for the former.
198+
199+
### The runtime
200+
201+
`readNsRuntimeDevHostApi()` returns the `ns:module` builtin: `getLoadedModuleUrls()` is the authoritative answer to "has this realm evaluated that module" (worker scripts and type-only files have not), and `invalidateModules(urls)` is the eviction primitive the shared `invalidateModulesByUrls` wraps. Both are `{}`-safe off-device, so strategies unit-test under Node.
202+
203+
### What the device must be able to load
204+
205+
The client module is served at `/ns/m/node_modules/<package>/<file>` — the registry resolves your `client` specifier from the app root and expresses it relative to the package, so a linked workspace package works the same as an installed one. The package is classified as dev tooling, like `@nativescript/vite`: served per-module, never vendor-bundled, never wrapped as a plugin. That is why relative imports inside it (`./boundary-propagation.js`) and its imports of the shared surface resolve to the same canonical URLs the running client uses — and why importing any other client-internal path is unsupported.
206+
207+
## 4. Testing a strategy without a device
208+
209+
Keep the decision logic pure. Octane's reverse-graph walk is a function `(changedIds, graph, { acceptsSelf, acceptsDep }) → { boundaries, evict, dead }` with a dozen cases that run in milliseconds. The strategy itself is testable against the real hot registry:
210+
211+
```ts
212+
import { getNsHotRegistry } from '@nativescript/vite/hmr/client/framework.js';
213+
import { octaneClientStrategy } from './strategy.js';
214+
215+
const hot = getNsHotRegistry().createHotContext('/src/app.tsx');
216+
hot.accept(gen1);
217+
octaneClientStrategy.beforeBatchEvict!(['/src/app.tsx']);
218+
getNsHotRegistry().createHotContext('/src/app.tsx').accept(gen2); // the fresh evaluation
219+
octaneClientStrategy.afterModuleReimport!('/src/app.tsx', { App: 'v2' });
220+
expect(gen1).toHaveBeenCalledWith({ App: 'v2' });
221+
```
222+
223+
On a device, five saves cover the matrix: a component, a plain dependency, a worker script, a registry-style module that accepts itself, and the entry.
224+
225+
## Checklist
226+
227+
- [ ] `registerFrameworkFlavor` runs before `baseConfig({ flavor })`.
228+
- [ ] Server strategy spreads `typescriptServerStrategy`, sets `flavor` and `deferDeltaBroadcast: true`, purges before it broadcasts.
229+
- [ ] Client module: plain ESM, `.js` extensions, imports only from `@nativescript/vite/hmr/client/framework.js`, exports `default`.
230+
- [ ] `package.json` exports `./client` and declares `nativescript.vite.flavor` (+ `config` for `init`).
231+
- [ ] `shouldQueueReimport` declines modules the main realm never loaded.
232+
- [ ] Every path ends the overlay: `setUpdateStage('complete', …)`.
233+
- [ ] A failed re-import leaves the app on the previous revision and the next good save applies in place.
234+
235+
## Reference
236+
237+
- `@nativescript/vite/framework` — `registerFrameworkFlavor`, `getFrameworkFlavor`, `baseConfig`, `getTypeCheckPlugins`, `typescriptServerStrategy`, `runHotUpdatePrologue`, `purgeTransformCachesForHotUpdate`, and the strategy types.
238+
- `@nativescript/vite/hmr/client/framework.js` — `getNsHotRegistry`, `graph`, `getGraphVersion`, `normalizeSpec`, `requestModuleFromServer`, `invalidateModulesByUrls`, `buildEvictionUrls`, `resolveHmrHttpOrigin`, `safeDynImport`, `getCore`, `ENV_VERBOSE`, `setUpdateStage`, `getOverlayApi`, `performResetRoot`, `getGlobalScope`, `readNsRuntimeDevHostApi`.
239+
- Worked example: [`@nativescript-community/vite-octane`](https://github.com/nativescript-community/octane/tree/main/packages/vite-octane).

‎content/configuration/vite.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,27 @@ export default defineConfig(({ mode }): UserConfig => {
145145
})
146146
```
147147

148+
### Octane
149+
150+
[Octane](https://octanejs.dev) support ships as a community package, [`@nativescript-community/vite-octane`](https://github.com/nativescript-community/octane), rather than inside `@nativescript/vite`:
151+
152+
```ts
153+
import { defineConfig, UserConfig } from 'vite'
154+
import { octaneConfig } from '@nativescript-community/vite-octane'
155+
import { nativeScriptRenderers } from './src/octane/config'
156+
157+
export default defineConfig(
158+
({ mode }): UserConfig =>
159+
octaneConfig({ mode }, { octane: { renderers: nativeScriptRenderers } }),
160+
)
161+
```
162+
163+
`octane.renderers` is the renderer config `@octanejs/vite-plugin` would otherwise read from `octane.config.ts`; a NativeScript renderer is not one of Octane's built-ins, so the app supplies its own. See [ns-octane](https://github.com/NathanWalker/ns-octane) for a complete app.
164+
165+
### Other frameworks
166+
167+
Any framework can provide its own flavor — a config helper, a server strategy and a device-side HMR strategy — from its own package, using the public flavor API. The Octane package above is built entirely on it. See [Framework Flavors](/configuration/vite-framework-flavors).
168+
148169
The above config configures most things required to bundle a NativeScript application.
149170

150171
This page contains examples of common things you might want to change in the [Examples of configurations section](#configuration-examples) - for anything else not mentioned here, refer to the [Vite documentation](https://vite.dev/config/).

‎content/sidebar.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,10 @@ export default [
160160
text: 'Vite Reference',
161161
link: '/configuration/vite',
162162
},
163+
{
164+
text: 'Vite Framework Flavors',
165+
link: '/configuration/vite-framework-flavors',
166+
},
163167
{
164168
text: 'Webpack Reference',
165169
link: '/configuration/webpack',

0 commit comments

Comments
 (0)