Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions packages/vite/docs/framework-flavors.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,23 @@ Register at module scope, before `baseConfig` can run. `baseConfig` installs the

`getTypeCheckPlugins` takes the *kind* of type-check, not the flavor name: `'typescript'` for a `.ts`/`.tsx` project, `'vue'` for `vue-tsc`.

### Keeping a runtime package out of the dev vendor bundle

A dev session evaluates node_modules code from one bundle, seeded on the very first boot from every root in the app's `dependencies`. That seed is a guess at what the device will need, and a compiler can make it wrong: Octane rewrites `import { useState } from 'octane'` in the components it compiles to `@nativescript-community/octane`, which imports only `octane/universal/native`. Left in the seed, the `octane` root drags its whole DOM-side runtime into every dev boot for nothing.

The flavor is the one party that knows this, so it declares it:

```ts
registerFrameworkFlavor({
flavor: 'octane',
server: octaneServerStrategy,
client: '@nativescript-community/vite-octane/client',
vendor: { exclude: ['octane'] },
});
```

`vendor.exclude` names package roots the vendor collection must not seed, even though the app depends on them directly; they are not traversed for peer dependencies either. An excluded package still reaches the bundle through whichever vendored package imports it (`@nativescript-community/octane` above), and anything the device imports that the bundle lacks is served per-module and recorded for the next boot. The semantics match the `NS_VENDOR_EXCLUDE` environment variable an app can set itself.

## 2. The server strategy

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.
Expand Down Expand Up @@ -230,6 +247,7 @@ On a device, five saves cover the matrix: a component, a plain dependency, a wor
- [ ] Server strategy spreads `typescriptServerStrategy`, sets `flavor` and `deferDeltaBroadcast: true`, purges before it broadcasts.
- [ ] Client module: plain ESM, `.js` extensions, imports only from `@nativescript/vite/hmr/client/framework.js`, exports `default`.
- [ ] `package.json` exports `./client` and declares `nativescript.vite.flavor` (+ `config` for `init`).
- [ ] `vendor.exclude` lists runtime packages the compiler rewrites away, so the dev deps bundle is not seeded with them.
- [ ] `shouldQueueReimport` declines modules the main realm never loaded.
- [ ] Every path ends the overlay: `setUpdateStage('complete', …)`.
- [ ] A failed re-import leaves the app on the previous revision and the next good save applies in place.
Expand Down
18 changes: 17 additions & 1 deletion packages/vite/hmr/framework-flavors.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import * as path from 'node:path';
import { describe, expect, it } from 'vitest';
import { getClientStrategyDevicePath, getFrameworkFlavor, registerFrameworkFlavor } from './framework-flavors.js';
import { getClientStrategyDevicePath, getFlavorVendorExcludes, getFrameworkFlavor, registerFrameworkFlavor } from './framework-flavors.js';
import { typescriptServerStrategy } from './frameworks/typescript/server/strategy.js';

function fakeProject(): { root: string } {
Expand Down Expand Up @@ -54,3 +54,19 @@ describe('getClientStrategyDevicePath', () => {
expect(getClientStrategyDevicePath('linked', root)).toBe('/ns/m/node_modules/@acme/linked-fw/client/index.js');
});
});

describe('vendor policy', () => {
it('rejects a malformed vendor.exclude', () => {
expect(() => registerFrameworkFlavor({ flavor: 'fw', server, client: 'x', vendor: { exclude: 'octane' as unknown as string[] } })).toThrow(/vendor\.exclude/);
expect(() => registerFrameworkFlavor({ flavor: 'fw', server, client: 'x', vendor: { exclude: [''] } })).toThrow(/vendor\.exclude/);
});

it('exposes the excluded package roots of the registered flavor only', () => {
registerFrameworkFlavor({ flavor: 'fw', server, client: 'x', vendor: { exclude: [' octane ', 'react-dom'] } });
expect([...getFlavorVendorExcludes('fw')]).toEqual(['octane', 'react-dom']);
expect(getFlavorVendorExcludes('vue').size).toBe(0);
expect(getFlavorVendorExcludes(undefined).size).toBe(0);
registerFrameworkFlavor({ flavor: 'fw', server, client: 'x' });
expect(getFlavorVendorExcludes('fw').size).toBe(0);
});
});
26 changes: 26 additions & 0 deletions packages/vite/hmr/framework-flavors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,19 @@ export interface FrameworkFlavorDefinition {
* the strategy as `default`, `clientStrategy`, or `<flavor>ClientStrategy`.
*/
client: string;
/**
* Dev-session vendor policy. `exclude` names package roots the vendor
* collection must not seed even though the app lists them in
* `dependencies` — the case for a runtime package the flavor's compiler
* stands in for (Octane rewrites `from 'octane'` in the components it
* compiles to its NativeScript renderer package, so seeding `octane` itself
* would evaluate its DOM runtime on device for nothing). An excluded package
* still reaches the bundle through whichever vendored package imports it.
* Same semantics as `NS_VENDOR_EXCLUDE`.
*/
vendor?: {
exclude?: readonly string[];
};
}

const BUILT_IN_FLAVORS = new Set(['angular', 'vue', 'react', 'solid', 'typescript', 'javascript']);
Expand Down Expand Up @@ -62,6 +75,13 @@ export function registerFrameworkFlavor(definition: FrameworkFlavorDefinition):
if (definition.server.flavor !== flavor) {
throw new Error(`[@nativescript/vite] registerFrameworkFlavor("${flavor}"): the server strategy declares flavor "${definition.server.flavor}".`);
}
if (definition.vendor !== undefined) {
const exclude = definition.vendor?.exclude;
const valid = typeof definition.vendor === 'object' && definition.vendor !== null && (exclude === undefined || (Array.isArray(exclude) && exclude.every((name) => typeof name === 'string' && name.trim())));
if (!valid) {
throw new TypeError(`[@nativescript/vite] registerFrameworkFlavor("${flavor}"): \`vendor.exclude\` must be an array of package names.`);
}
}
flavors.set(flavor, { ...definition, flavor });
}

Expand All @@ -85,6 +105,12 @@ export function getFlavorClientPackages(): ReadonlySet<string> {
return names;
}

/** Package roots a registered flavor keeps out of the dev vendor collection. */
export function getFlavorVendorExcludes(flavor: string | undefined): ReadonlySet<string> {
const exclude = flavor ? flavors.get(flavor)?.vendor?.exclude : undefined;
return new Set((exclude ?? []).map((name) => name.trim()).filter(Boolean));
}

function packageNameOfSpecifier(specifier: string): string | null {
if (!specifier || specifier.startsWith('.') || path.isAbsolute(specifier)) return null;
const parts = specifier.split('/');
Expand Down
9 changes: 9 additions & 0 deletions packages/vite/hmr/server/deps-bundle.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { afterAll, describe, expect, it } from 'vitest';

import { setUserDefineEntries } from '../../helpers/global-defines.js';
import { DEPS_BUNDLE_PATH, buildDepsBundleEntryCode, buildDepsCandidateSpecs, buildDepsShimCode, buildDepsVendorRuntimeModule, collectDepsModuleExportInfo, computeDepsBundleCacheKey, createDepsBundleService, depsRegistryKeyForFile, generateDepsBundle, isDepsPerModuleServingEnabled, resolveDepsEntriesFromRecording, resolveDepsEntriesFromVendorCollection } from './deps-bundle.js';
import { registerFrameworkFlavor } from '../framework-flavors.js';
import { typescriptServerStrategy } from '../frameworks/typescript/server/strategy.js';

describe('isDepsPerModuleServingEnabled', () => {
it('is off by default and on for 1/true', () => {
Expand Down Expand Up @@ -443,6 +445,13 @@ describe('resolveDepsEntriesFromVendorCollection', () => {
expect(specs).toContain('/node_modules/pkg-c');
});

it('leaves a package root the registered flavor excludes out of the seed and the vendor specifier map', () => {
registerFrameworkFlavor({ flavor: 'seed-fixture', server: { ...typescriptServerStrategy, flavor: 'seed-fixture' }, client: '@fixture/seed-flavor/client', vendor: { exclude: ['pkg-c'] } });
const seed = resolveDepsEntriesFromVendorCollection(projectRoot, null, 'ios', 'seed-fixture');
expect(seed.vendorSpecToKey.has('pkg-c')).toBe(false);
expect(seed.entries.map((e) => e.spec)).toEqual(['/node_modules/nativescript-widgets']);
});

it('returns an empty seed when the project has no collectable dependencies', () => {
const emptyRoot = mkdtempSync(path.join(realpathSync(tmpdir()), 'ns-deps-empty-'));
writeFileSync(path.join(emptyRoot, 'package.json'), JSON.stringify({ name: 'empty', version: '1.0.0' }));
Expand Down
30 changes: 16 additions & 14 deletions packages/vite/hmr/shared/vendor/manifest-collect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import path from 'path';
import fs, { readFileSync } from 'fs';
import { createRequire } from 'node:module';
import { getMonorepoWorkspaceRoot } from '../../../helpers/project.js';
import { getFlavorVendorExcludes } from '../../framework-flavors.js';

// Internal representation of resolved vendor inputs, including any metadata we
// need during esbuild bundling
Expand Down Expand Up @@ -384,14 +385,15 @@ export function collectVendorModules(projectRoot: string, platform: string, flav
// also skip them.
const localSourceNames = new Set<string>();

// Packages the user explicitly opted out of via NS_VENDOR_EXCLUDE. Applied
// here — not only as the final `vendor.delete()` sweep below — so an excluded
// package is never queued for peer-dependency traversal. Otherwise it can
// still drag its peers (notably build tools such as `vite`, declared as a
// peer of `@tanstack/solid-start`) into the bundle before it is itself
// removed. Anything genuinely needed at runtime but kept out of vendor is
// still served over HTTP by the dev server's module loader.
const envExcludes = new Set(parseEnvList(process.env.NS_VENDOR_EXCLUDE));
// Packages opted out via NS_VENDOR_EXCLUDE or by the registered flavor's
// vendor policy (a runtime package whose imports the flavor's compiler
// rewrites away). Applied here — not only as the final `vendor.delete()`
// sweep below — so an excluded package is never queued for peer-dependency
// traversal. Otherwise it can still drag its peers (notably build tools such
// as `vite`, declared as a peer of `@tanstack/solid-start`) into the bundle
// before it is itself removed. Anything genuinely needed at runtime but kept
// out of vendor is still served over HTTP by the dev server's module loader.
const policyExcludes = new Set([...parseEnvList(process.env.NS_VENDOR_EXCLUDE), ...getFlavorVendorExcludes(flavor)]);

const isPackageRootSpecifier = (name: string): boolean => {
if (!name) return false;
Expand All @@ -410,9 +412,9 @@ export function collectVendorModules(projectRoot: string, platform: string, flav
if (!name || shouldSkipDependency(name)) {
return;
}
// Honor NS_VENDOR_EXCLUDE up front so an opted-out package is neither
// vendored nor traversed for peers (see envExcludes above).
if (envExcludes.has(name)) {
// Honor the exclusion policy up front so an opted-out package is neither
// vendored nor traversed for peers (see policyExcludes above).
if (policyExcludes.has(name)) {
return;
}
// Avoid pulling Angular compiler/runtime into the dev vendor bundle when
Expand Down Expand Up @@ -518,7 +520,7 @@ export function collectVendorModules(projectRoot: string, platform: string, flav
// Policy exclusions match PACKAGE NAMES (`@prisma/client` in
// ALWAYS_EXCLUDE / NS_VENDOR_EXCLUDE); check here so subpath specs
// (`@prisma/client/runtime/…`) can't route around an excluded name.
if (shouldSkipDependency(name) || envExcludes.has(name)) {
if (shouldSkipDependency(name) || policyExcludes.has(name)) {
if (debug) console.log(`[vendor] skipping root dependency ${name} (excluded by policy)`);
continue;
}
Expand Down Expand Up @@ -646,9 +648,9 @@ export function collectVendorModules(projectRoot: string, platform: string, flav
// etc.). Only direct project dependencies go into the vendor bundle.
}

parseEnvList(process.env.NS_VENDOR_EXCLUDE).forEach((name) => {
for (const name of policyExcludes) {
vendor.delete(name);
});
}

return {
entries: Array.from(vendor).sort(),
Expand Down
22 changes: 22 additions & 0 deletions packages/vite/hmr/shared/vendor/manifest.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { afterEach, describe, expect, it } from 'vitest';

import { createVendorBundleRuntimeModule } from './manifest.js';
import { collectVendorModules } from './manifest-collect.js';
import { registerFrameworkFlavor } from '../../framework-flavors.js';
import { typescriptServerStrategy } from '../../frameworks/typescript/server/strategy.js';

describe('collectVendorModules', () => {
const tempRoots: string[] = [];
Expand Down Expand Up @@ -167,6 +169,26 @@ describe('collectVendorModules', () => {
expect(collected.entries).toContain('solid-js');
});

it('keeps a package root the registered flavor excludes out of vendor, even as a peer of a vendored package', () => {
// Octane shape: the app lists `octane` only to satisfy the renderer
// package's peer range, and the compiler rewrites every `from 'octane'`
// in compiled components to the renderer package. Seeding `octane` would
// evaluate its DOM runtime on device for nothing.
const root = mkdtempSync(join(tmpdir(), 'ns-vendor-manifest-'));
tempRoots.push(root);

writeFileSync(join(root, 'package.json'), JSON.stringify({ name: 'fixture-app', version: '0.0.0', dependencies: { '@acme/octane-ns': '*', octane: '*' } }, null, 2));
mkdirSync(join(root, 'node_modules', '@acme', 'octane-ns'), { recursive: true });
writeFileSync(join(root, 'node_modules', '@acme', 'octane-ns', 'package.json'), JSON.stringify({ name: '@acme/octane-ns', version: '1.0.0', peerDependencies: { octane: '*' } }, null, 2));
mkdirSync(join(root, 'node_modules', 'octane'), { recursive: true });
writeFileSync(join(root, 'node_modules', 'octane', 'package.json'), JSON.stringify({ name: 'octane', version: '1.0.0' }, null, 2));
registerFrameworkFlavor({ flavor: 'acme', server: { ...typescriptServerStrategy, flavor: 'acme' }, client: '@acme/octane-ns/client', vendor: { exclude: ['octane'] } });

expect(collectVendorModules(root, 'ios', 'acme').entries).toEqual(['@acme/octane-ns']);
// The policy belongs to the flavor that declared it.
expect(collectVendorModules(root, 'ios', 'typescript').entries).toEqual(['@acme/octane-ns', 'octane']);
});

it('emits the same runtime bundle contract for served vendor modules', () => {
const code = createVendorBundleRuntimeModule({
code: 'export const __nsVendorModuleMap = { "pinia": {} };\n',
Expand Down
Loading