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
24 changes: 24 additions & 0 deletions packages/vite/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,30 @@ export default {

3) Enjoy Vite.

### Runtime builtin modules (`ns:` / `node:`)

The NativeScript runtimes provide builtin modules under the `ns:` scheme (`ns:util`, `ns:module`, `ns:worker_threads`, …) and resolve them themselves. Every `ns:` import is left as-is in the output, including modules added in future runtime versions. This covers `vite build`, workers and HMR dev sessions.

To also use the runtime's `node:` shims, add another `nativescriptBuiltinModules` instance (only list the ones your runtime version ships; other `node:` imports become an empty stub). Use `resolve.alias` to forward bare names, which libraries often import, or to polyfill a builtin the runtime doesn't ship:

```ts
import { defineConfig, mergeConfig, UserConfig } from 'vite';
import { nativescriptBuiltinModules } from '@nativescript/vite';
import { typescriptConfig } from '@nativescript/vite/typescript';

export default defineConfig(({ mode }): UserConfig => {
return mergeConfig(typescriptConfig({ mode }), {
plugins: [nativescriptBuiltinModules({ include: ['node:worker_threads'] })],
resolve: {
alias: {
worker_threads: 'node:worker_threads',
'ns:url': '/abs/path/to/polyfills/ns-url.js',
},
},
});
});
```

## Explore More

Check out the [NativeScript Vite documentation](https://docs.nativescript.org/configuration/vite) for more configuration options and features.
7 changes: 5 additions & 2 deletions packages/vite/configuration/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import { getHmrWatchIgnoreGlobs } from '../helpers/hmr-scope.js';
import { NS_OPTIMIZE_DEPS_EXCLUDE } from '../helpers/optimize-deps.js';
import { sbgClassNamesPlugin } from '../helpers/sbg-class-names.js';
import { chunkFileNames } from '../helpers/chunk-file-names.js';
import { nativescriptBuiltinModules } from '../helpers/builtin-modules.js';
const require = createRequire(import.meta.url);
// Optional PostCSS import plugin (only used if available)
// Try to load postcss-import to control CSS @import resolution simply
Expand Down Expand Up @@ -308,7 +309,7 @@ export const baseConfig = ({ mode, flavor }: { mode: string; flavor?: string }):
'process.env.NODE_ENV': JSON.stringify(debug ? 'development' : 'production'),
},
},
plugins: [],
plugins: [nativescriptBuiltinModules()],
},
}
: {
Expand All @@ -325,7 +326,7 @@ export const baseConfig = ({ mode, flavor }: { mode: string; flavor?: string }):
'process.env.NODE_ENV': JSON.stringify(debug ? 'development' : 'production'),
},
},
plugins: [optimizeDepsPlatformResolver({ platform, verbose })],
plugins: [nativescriptBuiltinModules(), optimizeDepsPlatformResolver({ platform, verbose })],
},
};

Expand All @@ -343,6 +344,8 @@ export const baseConfig = ({ mode, flavor }: { mode: string; flavor?: string }):
define: defineConfig,
optimizeDeps: optimizeDepsConfig,
plugins: [
// Must stay first: Vite stubs `node:` builtins for the browser otherwise.
nativescriptBuiltinModules(),
// Merge any dependency-provided `nativescript.vite.mjs` configs. Runs
// in an awaited `config` hook so ESM configs load correctly.
externalConfigsPlugin(),
Expand Down
125 changes: 125 additions & 0 deletions packages/vite/helpers/builtin-modules.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import * as esbuild from 'esbuild';
import { build, type Plugin, type UserConfig } from 'vite';
import { afterAll, afterEach, describe, expect, it } from 'vitest';

import { builtinModulesEsbuildPlugin, isBuiltinModuleId, nativescriptBuiltinModules, resetBuiltinModules, unwrapBuiltinModuleIds } from './builtin-modules.js';

describe('builtin modules', () => {
afterEach(() => resetBuiltinModules());

describe('matching', () => {
it('matches every ns: specifier by default', () => {
expect(isBuiltinModuleId('ns:util')).toBe(true);
expect(isBuiltinModuleId('ns:some_future_module')).toBe(true);
expect(isBuiltinModuleId('node:worker_threads')).toBe(false);
expect(isBuiltinModuleId('worker_threads')).toBe(false);
});

it('adds the specifiers another plugin instance includes', () => {
nativescriptBuiltinModules({ include: ['node:worker_threads', /^node:(url|util)$/g] });

expect(isBuiltinModuleId('node:worker_threads')).toBe(true);
expect(isBuiltinModuleId('node:url')).toBe(true);
// a global RegExp must not carry lastIndex between calls
expect(isBuiltinModuleId('node:url')).toBe(true);
expect(isBuiltinModuleId('node:fs')).toBe(false);
});

it('restores /@id/-wrapped builtin specifiers only', () => {
nativescriptBuiltinModules({ include: ['node:worker_threads'] });
const code = [`import { inspect } from "/@id/ns:util";`, `export * from '/@id/node:worker_threads';`, `import("/@id/ns:runtime");`, `import z from "/@id/ns:util?t=123";`, `import x from "/@id/__x00__virtual";`, `import y from "/@id/node:fs";`].join('\n');

expect(unwrapBuiltinModuleIds(code)).toBe([`import { inspect } from "ns:util";`, `export * from 'node:worker_threads';`, `import("ns:runtime");`, `import z from "ns:util";`, `import x from "/@id/__x00__virtual";`, `import y from "/@id/node:fs";`].join('\n'));
});
});

describe('vite build', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ns-builtin-modules-'));
afterAll(() => fs.rmSync(root, { recursive: true, force: true }));

const write = (file: string, code: string) => {
fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true });
fs.writeFileSync(path.join(root, file), code);
};

const bundle = async (entry: string, plugins: Plugin[] = [nativescriptBuiltinModules()], resolve?: UserConfig['resolve']): Promise<string> => {
const result: any = await build({
root,
configFile: false,
logLevel: 'silent',
plugins,
resolve,
build: {
write: false,
minify: false,
rolldownOptions: { input: path.join(root, entry), output: { format: 'es', codeSplitting: false } },
},
});
return (Array.isArray(result) ? result[0] : result).output.map((file: any) => file.code ?? '').join('\n');
};

it('emits runtime builtin imports verbatim', async () => {
write('esm.js', [`import { inspect } from 'ns:util';`, `import { Worker } from 'worker_threads';`, `console.log(inspect, Worker, import('ns:runtime'));`].join('\n'));

const output = await bundle('esm.js', [nativescriptBuiltinModules(), nativescriptBuiltinModules({ include: ['node:worker_threads'] })], { alias: { worker_threads: 'node:worker_threads' } });

expect(output).toMatch(/from "ns:util"/);
expect(output).toMatch(/from "node:worker_threads"/);
expect(output).toContain('import("ns:runtime")');
});

it('keeps require() of builtins in CommonJS dependencies', async () => {
write('node_modules/cjs-dep/package.json', JSON.stringify({ name: 'cjs-dep', main: 'index.js' }));
write('node_modules/cjs-dep/index.js', `module.exports = require('ns:util').inspect;`);
write('cjs.js', `import inspect from 'cjs-dep';\nconsole.log(inspect);`);

expect(await bundle('cjs.js')).toMatch(/require\("ns:util"\)/);
});

it('lets resolve.alias polyfill a builtin', async () => {
write('url-polyfill.js', `export const URL = 'url-polyfill';`);
write('polyfill.js', `import { URL } from 'ns:url';\nconsole.log(URL);`);

const output = await bundle('polyfill.js', undefined, { alias: { 'ns:url': path.join(root, 'url-polyfill.js') } });

expect(output).not.toContain('"ns:url"');
expect(output).toContain('url-polyfill');
});

it('fails to resolve ns: specifiers without the plugin', async () => {
write('plain.js', `import { inspect } from 'ns:util';\nconsole.log(inspect);`);

await expect(bundle('plain.js', [])).rejects.toThrow(/ns:util/);
});
});

describe('esbuild bundles', () => {
it('externalizes builtins ahead of other resolvers', async () => {
nativescriptBuiltinModules({ include: ['node:worker_threads'] });
const result = await esbuild.build({
stdin: { contents: `import { inspect } from 'ns:util';\nimport { Worker } from 'node:worker_threads';\nconsole.log(inspect, Worker);`, loader: 'js' },
bundle: true,
write: false,
format: 'esm',
plugins: [
builtinModulesEsbuildPlugin(),
{
name: 'stub-everything',
setup(b) {
b.onResolve({ filter: /.*/ }, () => ({ path: 'stub', namespace: 'stub' }));
b.onLoad({ filter: /.*/, namespace: 'stub' }, () => ({ contents: 'throw new Error("stubbed")' }));
},
},
],
});
const output = result.outputFiles[0].text;

expect(output).toContain('from "ns:util"');
expect(output).toContain('from "node:worker_threads"');
expect(output).not.toContain('stubbed');
});
});
});
99 changes: 99 additions & 0 deletions packages/vite/helpers/builtin-modules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import type { Plugin } from 'vite';
import type { Plugin as EsbuildPlugin } from 'esbuild';

export interface NativeScriptBuiltinModulesOptions {
/**
* Additional specifiers the NativeScript runtime resolves itself, e.g. the
* `node:` shims a runtime version ships. Every `ns:` specifier is always included.
*/
include?: Array<string | RegExp>;
}

// Shared by every plugin instance: workers and the HMR server read it too.
const includes: Array<string | RegExp> = [/^ns:/];

/**
* Leaves imports of runtime builtin modules (`ns:util`, `ns:worker_threads`, ...)
* for the NativeScript runtime instead of bundling them. The base config already
* adds it for `ns:`; add another instance to include more specifiers. To polyfill
* a builtin instead, point a `resolve.alias` entry at the replacement module.
*
* @param options Extra specifiers to keep for the runtime.
* @returns The Vite plugin.
*/
export function nativescriptBuiltinModules(options: NativeScriptBuiltinModulesOptions = {}): Plugin {
for (const matcher of options.include ?? []) {
if (!includes.includes(matcher)) {
includes.push(matcher);
}
}
return {
name: 'ns-builtin-modules',
enforce: 'pre',
resolveId(source) {
return isBuiltinModuleId(source) ? { id: source, external: true } : null;
},
// The dev server still pre-transforms external ids; the device never fetches these.
load(id) {
if (isBuiltinModuleId(id)) {
return `throw new Error(${JSON.stringify(`${id} is a NativeScript runtime builtin and must be imported directly`)});`;
}
return null;
},
};
}

/**
* @internal
*/
export function resetBuiltinModules(): void {
includes.length = 0;
includes.push(/^ns:/);
}

/**
* True when `id` is a runtime builtin the output must reference verbatim.
*
* @internal
*/
export function isBuiltinModuleId(id: string): boolean {
if (!id) {
return false;
}
return includes.some((matcher) => {
if (typeof matcher === 'string') {
return matcher === id;
}
matcher.lastIndex = 0;
return matcher.test(id);
});
}

/**
* esbuild counterpart of `nativescriptBuiltinModules`, for bundles that bypass Vite plugins.
*
* @internal
*/
export function builtinModulesEsbuildPlugin(): EsbuildPlugin {
return {
name: 'ns-builtin-modules',
setup(build) {
build.onResolve({ filter: /^[^./]/ }, (args) => (isBuiltinModuleId(args.path) ? { path: args.path, external: true } : undefined));
},
};
}

const WRAPPED_ID_RE = /(["'`])\/@id\/([^"'`?#\s]+)(?:[?#][^"'`\s]*)?\1/g;

/**
* Restores `/@id/<builtin>` specifiers, which Vite's dev import analysis emits
* for external ids, to the verbatim specifier the runtime resolves.
*
* @internal
*/
export function unwrapBuiltinModuleIds(code: string): string {
if (!code || code.indexOf('/@id/') === -1) {
return code;
}
return code.replace(WRAPPED_ID_RE, (full, quote: string, id: string) => (isBuiltinModuleId(id) ? `${quote}${id}${quote}` : full));
}
4 changes: 4 additions & 0 deletions packages/vite/helpers/main-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,10 @@ export function mainEntryPlugin(opts: { platform: 'ios' | 'android' | 'visionos'
imports += ' var _nsReq = function (id) {\n';
imports += ' try {\n';
imports += " var n = String(id || '');\n";
// Kept-external builtins must reach the runtime before the stub table.
imports += " if (__nsOrigRequire && (n.indexOf('ns:') === 0 || n.indexOf('node:') === 0)) {\n";
imports += ' try { return __nsOrigRequire(n); } catch (e) {}\n';
imports += ' }\n';
imports += " var stripped = n.indexOf('node:') === 0 ? n.slice(5) : n;\n";
imports += ' if (__nsNodeBuiltins[stripped]) return {};\n';
imports += " if (n === '@nativescript/core' || n.indexOf('@nativescript/core/') === 0) {\n";
Expand Down
2 changes: 2 additions & 0 deletions packages/vite/helpers/workers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { nativescriptPackageResolver } from './nativescript-package-resolver.js'
import { findMonorepoWorkspaceRoot, getProjectRootPath } from './project.js';
import { normalizeModuleId } from './normalize-id.js';
import { sbgClassNamesPlugin } from './sbg-class-names.js';
import { nativescriptBuiltinModules } from './builtin-modules.js';

export interface WorkerPluginsOptions {
platform: string;
Expand Down Expand Up @@ -103,6 +104,7 @@ export function getWorkerPlugins(platformOrOpts: string | WorkerPluginsOptions)
});

const plugins: any[] = [
nativescriptBuiltinModules(),
// Handle ~/package.json virtual module for workers
{
name: 'worker-virtual-package-json',
Expand Down
51 changes: 51 additions & 0 deletions packages/vite/hmr/server/builtin-modules-hmr.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { afterEach, describe, expect, it } from 'vitest';

import { nativescriptBuiltinModules, resetBuiltinModules } from '../../helpers/builtin-modules.js';
import { rewriteSpecifiersForDevice } from './core-sanitize.js';
import { getNodeBuiltinImportMapEntries } from './node-builtins-route.js';
import { processCodeForDevice } from './process-code-for-device.js';
import { ensureNativeScriptModuleBindings } from './websocket-module-bindings.js';
import { isLikelyNativeScriptPluginSpecifier } from './websocket-module-specifiers.js';

const ORIGIN = 'http://localhost:5173';

describe('runtime builtin modules under HMR', () => {
afterEach(() => resetBuiltinModules());

it('serves /@id/-wrapped builtins to the device verbatim', () => {
nativescriptBuiltinModules({ include: ['node:worker_threads'] });
const code = [`import { inspect } from "/@id/ns:util";`, `import "/@id/ns:runtime";`, `export * from "/@id/node:worker_threads";`, `console.log(inspect);`].join('\n');

const output = processCodeForDevice(code, false, true, true, '/node_modules/dep/index.js');

expect(output).toContain(`from "ns:util"`);
expect(output).toContain(`import "ns:runtime"`);
expect(output).toContain(`from "node:worker_threads"`);
expect(output).not.toContain('/@id/');
});

it('keeps builtin imports out of the worker realm /ns/m routing', () => {
const output = ensureNativeScriptModuleBindings(`import { inspect } from 'ns:util';\nimport 'ns:runtime';\nconsole.log(inspect);`, { vendorImportsAsHttp: true });

expect(output).toContain(`import { inspect } from 'ns:util';`);
expect(output).toContain(`import 'ns:runtime';`);
expect(output).not.toContain('/ns/m/');
expect(isLikelyNativeScriptPluginSpecifier('ns:util')).toBe(false);
});

it('leaves builtins alone when rewriting core modules', () => {
const output = rewriteSpecifiersForDevice(`import { inspect } from "ns:util";\nimport { Worker } from "/@id/ns:worker_threads";`, ORIGIN, 0);

expect(output).toContain(`from "ns:util"`);
expect(output).toContain(`from "ns:worker_threads"`);
expect(output).not.toContain('/ns/m/');
});

it('drops opted-in node: builtins from the dev import map', () => {
nativescriptBuiltinModules({ include: ['node:worker_threads'] });
const entries = getNodeBuiltinImportMapEntries(ORIGIN);

expect(entries['node:worker_threads']).toBeUndefined();
expect(entries['node:fs']).toBe(`${ORIGIN}/ns/node/fs`);
});
});
3 changes: 2 additions & 1 deletion packages/vite/hmr/server/core-bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import { nsConfigToJson } from '../../helpers/utils.js';
import { getVitePackageVersion } from '../../helpers/vite-package-version.js';
import { aliasCssTree } from '../../helpers/css-tree.js';
import { getWorkspaceCoreSourceRoot } from './websocket-served-module-helpers.js';
import { builtinModulesEsbuildPlugin } from '../../helpers/builtin-modules.js';

/**
* Per-module /ns/core serving mode (vs the default single-eval bundle).
Expand Down Expand Up @@ -541,7 +542,7 @@ export async function generateCoreBundle(options: GenerateCoreBundleOptions): Pr
'.json': 'json',
},
define: defines,
plugins: [coreAliasPlugin, cssTreeEnvPlugin, nsConfigPlugin, createNativeClassEsbuildPlugin(platform as Platform), createUnicodeRegexEsbuildPlugin(projectRoot)],
plugins: [builtinModulesEsbuildPlugin(), coreAliasPlugin, cssTreeEnvPlugin, nsConfigPlugin, createNativeClassEsbuildPlugin(platform as Platform), createUnicodeRegexEsbuildPlugin(projectRoot)],
external: NODE_EXTERNALS,
});

Expand Down
Loading
Loading