Skip to content

Commit e532d62

Browse files
authored
feat(webpack,vite): keep ns: builtin module imports for the runtime (#11626) [skip ci]
The iOS and Android runtimes resolve `ns:<name>` builtin modules (and a few `node:<name>` compatibility shims) themselves. Webpack failed on these imports with an unhandled-scheme error, and Vite failed to resolve `ns:` specifiers and replaced `node:` ones with an empty browser stub. Every `ns:` specifier is now left verbatim in the output, matched by prefix so modules added in future runtime versions work without changes. Each bundler is configured through its own plugin: - webpack: NativeScriptBuiltinModulesPlugin, registered by name so webpack-chain can `.tap()` its `include` list. It externalizes on the request after `beforeResolve`, so a NormalModuleReplacementPlugin can redirect a builtin to a polyfill or forward a bare specifier to one. - vite: nativescriptBuiltinModules({ include }). Extra instances add specifiers; resolve.alias handles polyfills and forwarding, since aliases resolve before the plugin. In Vite this covers `vite build`, workers, dep optimization and the HMR dev server (esbuild dependency bundles, `/@id/` unwrapping, worker realm routing, core rewriting, the dev import map and the CommonJS require shim).
1 parent 07e84ec commit e532d62

28 files changed

Lines changed: 591 additions & 15 deletions

‎packages/vite/README.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,30 @@ export default {
190190

191191
3) Enjoy Vite.
192192

193+
### Runtime builtin modules (`ns:` / `node:`)
194+
195+
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.
196+
197+
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:
198+
199+
```ts
200+
import { defineConfig, mergeConfig, UserConfig } from 'vite';
201+
import { nativescriptBuiltinModules } from '@nativescript/vite';
202+
import { typescriptConfig } from '@nativescript/vite/typescript';
203+
204+
export default defineConfig(({ mode }): UserConfig => {
205+
return mergeConfig(typescriptConfig({ mode }), {
206+
plugins: [nativescriptBuiltinModules({ include: ['node:worker_threads'] })],
207+
resolve: {
208+
alias: {
209+
worker_threads: 'node:worker_threads',
210+
'ns:url': '/abs/path/to/polyfills/ns-url.js',
211+
},
212+
},
213+
});
214+
});
215+
```
216+
193217
## Explore More
194218

195219
Check out the [NativeScript Vite documentation](https://docs.nativescript.org/configuration/vite) for more configuration options and features.

‎packages/vite/configuration/base.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import { getHmrWatchIgnoreGlobs } from '../helpers/hmr-scope.js';
4646
import { NS_OPTIMIZE_DEPS_EXCLUDE } from '../helpers/optimize-deps.js';
4747
import { sbgClassNamesPlugin } from '../helpers/sbg-class-names.js';
4848
import { chunkFileNames } from '../helpers/chunk-file-names.js';
49+
import { nativescriptBuiltinModules } from '../helpers/builtin-modules.js';
4950
const require = createRequire(import.meta.url);
5051
// Optional PostCSS import plugin (only used if available)
5152
// Try to load postcss-import to control CSS @import resolution simply
@@ -308,7 +309,7 @@ export const baseConfig = ({ mode, flavor }: { mode: string; flavor?: string }):
308309
'process.env.NODE_ENV': JSON.stringify(debug ? 'development' : 'production'),
309310
},
310311
},
311-
plugins: [],
312+
plugins: [nativescriptBuiltinModules()],
312313
},
313314
}
314315
: {
@@ -325,7 +326,7 @@ export const baseConfig = ({ mode, flavor }: { mode: string; flavor?: string }):
325326
'process.env.NODE_ENV': JSON.stringify(debug ? 'development' : 'production'),
326327
},
327328
},
328-
plugins: [optimizeDepsPlatformResolver({ platform, verbose })],
329+
plugins: [nativescriptBuiltinModules(), optimizeDepsPlatformResolver({ platform, verbose })],
329330
},
330331
};
331332

@@ -343,6 +344,8 @@ export const baseConfig = ({ mode, flavor }: { mode: string; flavor?: string }):
343344
define: defineConfig,
344345
optimizeDeps: optimizeDepsConfig,
345346
plugins: [
347+
// Must stay first: Vite stubs `node:` builtins for the browser otherwise.
348+
nativescriptBuiltinModules(),
346349
// Merge any dependency-provided `nativescript.vite.mjs` configs. Runs
347350
// in an awaited `config` hook so ESM configs load correctly.
348351
externalConfigsPlugin(),
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import fs from 'fs';
2+
import os from 'os';
3+
import path from 'path';
4+
import * as esbuild from 'esbuild';
5+
import { build, type Plugin, type UserConfig } from 'vite';
6+
import { afterAll, afterEach, describe, expect, it } from 'vitest';
7+
8+
import { builtinModulesEsbuildPlugin, isBuiltinModuleId, nativescriptBuiltinModules, resetBuiltinModules, unwrapBuiltinModuleIds } from './builtin-modules.js';
9+
10+
describe('builtin modules', () => {
11+
afterEach(() => resetBuiltinModules());
12+
13+
describe('matching', () => {
14+
it('matches every ns: specifier by default', () => {
15+
expect(isBuiltinModuleId('ns:util')).toBe(true);
16+
expect(isBuiltinModuleId('ns:some_future_module')).toBe(true);
17+
expect(isBuiltinModuleId('node:worker_threads')).toBe(false);
18+
expect(isBuiltinModuleId('worker_threads')).toBe(false);
19+
});
20+
21+
it('adds the specifiers another plugin instance includes', () => {
22+
nativescriptBuiltinModules({ include: ['node:worker_threads', /^node:(url|util)$/g] });
23+
24+
expect(isBuiltinModuleId('node:worker_threads')).toBe(true);
25+
expect(isBuiltinModuleId('node:url')).toBe(true);
26+
// a global RegExp must not carry lastIndex between calls
27+
expect(isBuiltinModuleId('node:url')).toBe(true);
28+
expect(isBuiltinModuleId('node:fs')).toBe(false);
29+
});
30+
31+
it('restores /@id/-wrapped builtin specifiers only', () => {
32+
nativescriptBuiltinModules({ include: ['node:worker_threads'] });
33+
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');
34+
35+
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'));
36+
});
37+
});
38+
39+
describe('vite build', () => {
40+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ns-builtin-modules-'));
41+
afterAll(() => fs.rmSync(root, { recursive: true, force: true }));
42+
43+
const write = (file: string, code: string) => {
44+
fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true });
45+
fs.writeFileSync(path.join(root, file), code);
46+
};
47+
48+
const bundle = async (entry: string, plugins: Plugin[] = [nativescriptBuiltinModules()], resolve?: UserConfig['resolve']): Promise<string> => {
49+
const result: any = await build({
50+
root,
51+
configFile: false,
52+
logLevel: 'silent',
53+
plugins,
54+
resolve,
55+
build: {
56+
write: false,
57+
minify: false,
58+
rolldownOptions: { input: path.join(root, entry), output: { format: 'es', codeSplitting: false } },
59+
},
60+
});
61+
return (Array.isArray(result) ? result[0] : result).output.map((file: any) => file.code ?? '').join('\n');
62+
};
63+
64+
it('emits runtime builtin imports verbatim', async () => {
65+
write('esm.js', [`import { inspect } from 'ns:util';`, `import { Worker } from 'worker_threads';`, `console.log(inspect, Worker, import('ns:runtime'));`].join('\n'));
66+
67+
const output = await bundle('esm.js', [nativescriptBuiltinModules(), nativescriptBuiltinModules({ include: ['node:worker_threads'] })], { alias: { worker_threads: 'node:worker_threads' } });
68+
69+
expect(output).toMatch(/from "ns:util"/);
70+
expect(output).toMatch(/from "node:worker_threads"/);
71+
expect(output).toContain('import("ns:runtime")');
72+
});
73+
74+
it('keeps require() of builtins in CommonJS dependencies', async () => {
75+
write('node_modules/cjs-dep/package.json', JSON.stringify({ name: 'cjs-dep', main: 'index.js' }));
76+
write('node_modules/cjs-dep/index.js', `module.exports = require('ns:util').inspect;`);
77+
write('cjs.js', `import inspect from 'cjs-dep';\nconsole.log(inspect);`);
78+
79+
expect(await bundle('cjs.js')).toMatch(/require\("ns:util"\)/);
80+
});
81+
82+
it('lets resolve.alias polyfill a builtin', async () => {
83+
write('url-polyfill.js', `export const URL = 'url-polyfill';`);
84+
write('polyfill.js', `import { URL } from 'ns:url';\nconsole.log(URL);`);
85+
86+
const output = await bundle('polyfill.js', undefined, { alias: { 'ns:url': path.join(root, 'url-polyfill.js') } });
87+
88+
expect(output).not.toContain('"ns:url"');
89+
expect(output).toContain('url-polyfill');
90+
});
91+
92+
it('fails to resolve ns: specifiers without the plugin', async () => {
93+
write('plain.js', `import { inspect } from 'ns:util';\nconsole.log(inspect);`);
94+
95+
await expect(bundle('plain.js', [])).rejects.toThrow(/ns:util/);
96+
});
97+
});
98+
99+
describe('esbuild bundles', () => {
100+
it('externalizes builtins ahead of other resolvers', async () => {
101+
nativescriptBuiltinModules({ include: ['node:worker_threads'] });
102+
const result = await esbuild.build({
103+
stdin: { contents: `import { inspect } from 'ns:util';\nimport { Worker } from 'node:worker_threads';\nconsole.log(inspect, Worker);`, loader: 'js' },
104+
bundle: true,
105+
write: false,
106+
format: 'esm',
107+
plugins: [
108+
builtinModulesEsbuildPlugin(),
109+
{
110+
name: 'stub-everything',
111+
setup(b) {
112+
b.onResolve({ filter: /.*/ }, () => ({ path: 'stub', namespace: 'stub' }));
113+
b.onLoad({ filter: /.*/, namespace: 'stub' }, () => ({ contents: 'throw new Error("stubbed")' }));
114+
},
115+
},
116+
],
117+
});
118+
const output = result.outputFiles[0].text;
119+
120+
expect(output).toContain('from "ns:util"');
121+
expect(output).toContain('from "node:worker_threads"');
122+
expect(output).not.toContain('stubbed');
123+
});
124+
});
125+
});
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import type { Plugin } from 'vite';
2+
import type { Plugin as EsbuildPlugin } from 'esbuild';
3+
4+
export interface NativeScriptBuiltinModulesOptions {
5+
/**
6+
* Additional specifiers the NativeScript runtime resolves itself, e.g. the
7+
* `node:` shims a runtime version ships. Every `ns:` specifier is always included.
8+
*/
9+
include?: Array<string | RegExp>;
10+
}
11+
12+
// Shared by every plugin instance: workers and the HMR server read it too.
13+
const includes: Array<string | RegExp> = [/^ns:/];
14+
15+
/**
16+
* Leaves imports of runtime builtin modules (`ns:util`, `ns:worker_threads`, ...)
17+
* for the NativeScript runtime instead of bundling them. The base config already
18+
* adds it for `ns:`; add another instance to include more specifiers. To polyfill
19+
* a builtin instead, point a `resolve.alias` entry at the replacement module.
20+
*
21+
* @param options Extra specifiers to keep for the runtime.
22+
* @returns The Vite plugin.
23+
*/
24+
export function nativescriptBuiltinModules(options: NativeScriptBuiltinModulesOptions = {}): Plugin {
25+
for (const matcher of options.include ?? []) {
26+
if (!includes.includes(matcher)) {
27+
includes.push(matcher);
28+
}
29+
}
30+
return {
31+
name: 'ns-builtin-modules',
32+
enforce: 'pre',
33+
resolveId(source) {
34+
return isBuiltinModuleId(source) ? { id: source, external: true } : null;
35+
},
36+
// The dev server still pre-transforms external ids; the device never fetches these.
37+
load(id) {
38+
if (isBuiltinModuleId(id)) {
39+
return `throw new Error(${JSON.stringify(`${id} is a NativeScript runtime builtin and must be imported directly`)});`;
40+
}
41+
return null;
42+
},
43+
};
44+
}
45+
46+
/**
47+
* @internal
48+
*/
49+
export function resetBuiltinModules(): void {
50+
includes.length = 0;
51+
includes.push(/^ns:/);
52+
}
53+
54+
/**
55+
* True when `id` is a runtime builtin the output must reference verbatim.
56+
*
57+
* @internal
58+
*/
59+
export function isBuiltinModuleId(id: string): boolean {
60+
if (!id) {
61+
return false;
62+
}
63+
return includes.some((matcher) => {
64+
if (typeof matcher === 'string') {
65+
return matcher === id;
66+
}
67+
matcher.lastIndex = 0;
68+
return matcher.test(id);
69+
});
70+
}
71+
72+
/**
73+
* esbuild counterpart of `nativescriptBuiltinModules`, for bundles that bypass Vite plugins.
74+
*
75+
* @internal
76+
*/
77+
export function builtinModulesEsbuildPlugin(): EsbuildPlugin {
78+
return {
79+
name: 'ns-builtin-modules',
80+
setup(build) {
81+
build.onResolve({ filter: /^[^./]/ }, (args) => (isBuiltinModuleId(args.path) ? { path: args.path, external: true } : undefined));
82+
},
83+
};
84+
}
85+
86+
const WRAPPED_ID_RE = /(["'`])\/@id\/([^"'`?#\s]+)(?:[?#][^"'`\s]*)?\1/g;
87+
88+
/**
89+
* Restores `/@id/<builtin>` specifiers, which Vite's dev import analysis emits
90+
* for external ids, to the verbatim specifier the runtime resolves.
91+
*
92+
* @internal
93+
*/
94+
export function unwrapBuiltinModuleIds(code: string): string {
95+
if (!code || code.indexOf('/@id/') === -1) {
96+
return code;
97+
}
98+
return code.replace(WRAPPED_ID_RE, (full, quote: string, id: string) => (isBuiltinModuleId(id) ? `${quote}${id}${quote}` : full));
99+
}

‎packages/vite/helpers/main-entry.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,10 @@ export function mainEntryPlugin(opts: { platform: 'ios' | 'android' | 'visionos'
498498
imports += ' var _nsReq = function (id) {\n';
499499
imports += ' try {\n';
500500
imports += " var n = String(id || '');\n";
501+
// Kept-external builtins must reach the runtime before the stub table.
502+
imports += " if (__nsOrigRequire && (n.indexOf('ns:') === 0 || n.indexOf('node:') === 0)) {\n";
503+
imports += ' try { return __nsOrigRequire(n); } catch (e) {}\n';
504+
imports += ' }\n';
501505
imports += " var stripped = n.indexOf('node:') === 0 ? n.slice(5) : n;\n";
502506
imports += ' if (__nsNodeBuiltins[stripped]) return {};\n';
503507
imports += " if (n === '@nativescript/core' || n.indexOf('@nativescript/core/') === 0) {\n";

‎packages/vite/helpers/workers.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { nativescriptPackageResolver } from './nativescript-package-resolver.js'
77
import { findMonorepoWorkspaceRoot, getProjectRootPath } from './project.js';
88
import { normalizeModuleId } from './normalize-id.js';
99
import { sbgClassNamesPlugin } from './sbg-class-names.js';
10+
import { nativescriptBuiltinModules } from './builtin-modules.js';
1011

1112
export interface WorkerPluginsOptions {
1213
platform: string;
@@ -103,6 +104,7 @@ export function getWorkerPlugins(platformOrOpts: string | WorkerPluginsOptions)
103104
});
104105

105106
const plugins: any[] = [
107+
nativescriptBuiltinModules(),
106108
// Handle ~/package.json virtual module for workers
107109
{
108110
name: 'worker-virtual-package-json',
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { afterEach, describe, expect, it } from 'vitest';
2+
3+
import { nativescriptBuiltinModules, resetBuiltinModules } from '../../helpers/builtin-modules.js';
4+
import { rewriteSpecifiersForDevice } from './core-sanitize.js';
5+
import { getNodeBuiltinImportMapEntries } from './node-builtins-route.js';
6+
import { processCodeForDevice } from './process-code-for-device.js';
7+
import { ensureNativeScriptModuleBindings } from './websocket-module-bindings.js';
8+
import { isLikelyNativeScriptPluginSpecifier } from './websocket-module-specifiers.js';
9+
10+
const ORIGIN = 'http://localhost:5173';
11+
12+
describe('runtime builtin modules under HMR', () => {
13+
afterEach(() => resetBuiltinModules());
14+
15+
it('serves /@id/-wrapped builtins to the device verbatim', () => {
16+
nativescriptBuiltinModules({ include: ['node:worker_threads'] });
17+
const code = [`import { inspect } from "/@id/ns:util";`, `import "/@id/ns:runtime";`, `export * from "/@id/node:worker_threads";`, `console.log(inspect);`].join('\n');
18+
19+
const output = processCodeForDevice(code, false, true, true, '/node_modules/dep/index.js');
20+
21+
expect(output).toContain(`from "ns:util"`);
22+
expect(output).toContain(`import "ns:runtime"`);
23+
expect(output).toContain(`from "node:worker_threads"`);
24+
expect(output).not.toContain('/@id/');
25+
});
26+
27+
it('keeps builtin imports out of the worker realm /ns/m routing', () => {
28+
const output = ensureNativeScriptModuleBindings(`import { inspect } from 'ns:util';\nimport 'ns:runtime';\nconsole.log(inspect);`, { vendorImportsAsHttp: true });
29+
30+
expect(output).toContain(`import { inspect } from 'ns:util';`);
31+
expect(output).toContain(`import 'ns:runtime';`);
32+
expect(output).not.toContain('/ns/m/');
33+
expect(isLikelyNativeScriptPluginSpecifier('ns:util')).toBe(false);
34+
});
35+
36+
it('leaves builtins alone when rewriting core modules', () => {
37+
const output = rewriteSpecifiersForDevice(`import { inspect } from "ns:util";\nimport { Worker } from "/@id/ns:worker_threads";`, ORIGIN, 0);
38+
39+
expect(output).toContain(`from "ns:util"`);
40+
expect(output).toContain(`from "ns:worker_threads"`);
41+
expect(output).not.toContain('/ns/m/');
42+
});
43+
44+
it('drops opted-in node: builtins from the dev import map', () => {
45+
nativescriptBuiltinModules({ include: ['node:worker_threads'] });
46+
const entries = getNodeBuiltinImportMapEntries(ORIGIN);
47+
48+
expect(entries['node:worker_threads']).toBeUndefined();
49+
expect(entries['node:fs']).toBe(`${ORIGIN}/ns/node/fs`);
50+
});
51+
});

‎packages/vite/hmr/server/core-bundle.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import { nsConfigToJson } from '../../helpers/utils.js';
5252
import { getVitePackageVersion } from '../../helpers/vite-package-version.js';
5353
import { aliasCssTree } from '../../helpers/css-tree.js';
5454
import { getWorkspaceCoreSourceRoot } from './websocket-served-module-helpers.js';
55+
import { builtinModulesEsbuildPlugin } from '../../helpers/builtin-modules.js';
5556

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

0 commit comments

Comments
 (0)