|
| 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). |
0 commit comments