feat(schema,nuxt): add shared/ folder and #shared alias (#28682)

This commit is contained in:
Estéban 2024-11-02 22:13:41 +01:00 committed by Daniel Roe
parent 9fdf90cbde
commit 18d547d5a5
No known key found for this signature in database
GPG Key ID: CBC814C393D93268
10 changed files with 102 additions and 33 deletions

View File

@ -17,7 +17,7 @@ import { version as nuxtVersion } from '../../package.json'
import { distDir } from '../dirs' import { distDir } from '../dirs'
import { toArray } from '../utils' import { toArray } from '../utils'
import { template as defaultSpaLoadingTemplate } from '../../../ui-templates/dist/templates/spa-loading-icon' import { template as defaultSpaLoadingTemplate } from '../../../ui-templates/dist/templates/spa-loading-icon'
import { nuxtImportProtections } from './plugins/import-protection' import { createImportProtectionPatterns } from './plugins/import-protection'
import { EXTENSION_RE } from './utils' import { EXTENSION_RE } from './utils'
const logLevelMapReverse = { const logLevelMapReverse = {
@ -49,6 +49,8 @@ export async function initNitro (nuxt: Nuxt & { _nitro?: Nitro }) {
.map(m => m.entryPath!), .map(m => m.entryPath!),
) )
const isNuxtV4 = nuxt.options.future?.compatibilityVersion === 4
const nitroConfig: NitroConfig = defu(nuxt.options.nitro, { const nitroConfig: NitroConfig = defu(nuxt.options.nitro, {
debug: nuxt.options.debug, debug: nuxt.options.debug,
rootDir: nuxt.options.rootDir, rootDir: nuxt.options.rootDir,
@ -66,6 +68,12 @@ export async function initNitro (nuxt: Nuxt & { _nitro?: Nitro }) {
}, },
imports: { imports: {
autoImport: nuxt.options.imports.autoImport as boolean, autoImport: nuxt.options.imports.autoImport as boolean,
dirs: isNuxtV4
? [
resolve(nuxt.options.rootDir, 'shared', 'utils'),
resolve(nuxt.options.rootDir, 'shared', 'types'),
]
: [],
imports: [ imports: [
{ {
as: '__buildAssetsURL', as: '__buildAssetsURL',
@ -362,11 +370,20 @@ export async function initNitro (nuxt: Nuxt & { _nitro?: Nitro }) {
// Register nuxt protection patterns // Register nuxt protection patterns
nitroConfig.rollupConfig!.plugins = await nitroConfig.rollupConfig!.plugins || [] nitroConfig.rollupConfig!.plugins = await nitroConfig.rollupConfig!.plugins || []
nitroConfig.rollupConfig!.plugins = toArray(nitroConfig.rollupConfig!.plugins) nitroConfig.rollupConfig!.plugins = toArray(nitroConfig.rollupConfig!.plugins)
const sharedDir = withTrailingSlash(resolve(nuxt.options.rootDir, nuxt.options.dir.shared))
const relativeSharedDir = withTrailingSlash(relative(nuxt.options.rootDir, resolve(nuxt.options.rootDir, nuxt.options.dir.shared)))
const sharedPatterns = [/^#shared\//, new RegExp('^' + escapeRE(sharedDir)), new RegExp('^' + escapeRE(relativeSharedDir))]
nitroConfig.rollupConfig!.plugins!.push( nitroConfig.rollupConfig!.plugins!.push(
ImpoundPlugin.rollup({ ImpoundPlugin.rollup({
cwd: nuxt.options.rootDir, cwd: nuxt.options.rootDir,
patterns: nuxtImportProtections(nuxt, { isNitro: true }), include: sharedPatterns,
exclude: [/core[\\/]runtime[\\/]nitro[\\/]renderer/], patterns: createImportProtectionPatterns(nuxt, { context: 'shared' }),
}),
ImpoundPlugin.rollup({
cwd: nuxt.options.rootDir,
patterns: createImportProtectionPatterns(nuxt, { context: 'nitro-app' }),
exclude: [/core[\\/]runtime[\\/]nitro[\\/]renderer/, ...sharedPatterns],
}), }),
) )

View File

@ -18,7 +18,6 @@ import type { DateString } from 'compatx'
import escapeRE from 'escape-string-regexp' import escapeRE from 'escape-string-regexp'
import { withTrailingSlash, withoutLeadingSlash } from 'ufo' import { withTrailingSlash, withoutLeadingSlash } from 'ufo'
import { ImpoundPlugin } from 'impound' import { ImpoundPlugin } from 'impound'
import type { ImpoundOptions } from 'impound'
import defu from 'defu' import defu from 'defu'
import { gt, satisfies } from 'semver' import { gt, satisfies } from 'semver'
import { hasTTY, isCI } from 'std-env' import { hasTTY, isCI } from 'std-env'
@ -32,7 +31,7 @@ import { distDir, pkgDir } from '../dirs'
import { version } from '../../package.json' import { version } from '../../package.json'
import { scriptsStubsPreset } from '../imports/presets' import { scriptsStubsPreset } from '../imports/presets'
import { resolveTypePath } from './utils/types' import { resolveTypePath } from './utils/types'
import { nuxtImportProtections } from './plugins/import-protection' import { createImportProtectionPatterns } from './plugins/import-protection'
import { UnctxTransformPlugin } from './plugins/unctx' import { UnctxTransformPlugin } from './plugins/unctx'
import { TreeShakeComposablesPlugin } from './plugins/tree-shake' import { TreeShakeComposablesPlugin } from './plugins/tree-shake'
import { DevOnlyPlugin } from './plugins/dev-only' import { DevOnlyPlugin } from './plugins/dev-only'
@ -249,16 +248,28 @@ async function initNuxt (nuxt: Nuxt) {
// Add plugin normalization plugin // Add plugin normalization plugin
addBuildPlugin(RemovePluginMetadataPlugin(nuxt)) addBuildPlugin(RemovePluginMetadataPlugin(nuxt))
// shared folder import protection
const sharedDir = withTrailingSlash(resolve(nuxt.options.rootDir, nuxt.options.dir.shared))
const relativeSharedDir = withTrailingSlash(relative(nuxt.options.rootDir, resolve(nuxt.options.rootDir, nuxt.options.dir.shared)))
const sharedPatterns = [/^#shared\//, new RegExp('^' + escapeRE(sharedDir)), new RegExp('^' + escapeRE(relativeSharedDir))]
const sharedProtectionConfig = {
cwd: nuxt.options.rootDir,
include: sharedPatterns,
patterns: createImportProtectionPatterns(nuxt, { context: 'shared' }),
}
addVitePlugin(() => ImpoundPlugin.vite(sharedProtectionConfig), { server: false })
addWebpackPlugin(() => ImpoundPlugin.webpack(sharedProtectionConfig), { server: false })
// Add import protection // Add import protection
const config: ImpoundOptions = { const nuxtProtectionConfig = {
cwd: nuxt.options.rootDir, cwd: nuxt.options.rootDir,
// Exclude top-level resolutions by plugins // Exclude top-level resolutions by plugins
exclude: [join(nuxt.options.srcDir, 'index.html')], exclude: [relative(nuxt.options.rootDir, join(nuxt.options.srcDir, 'index.html')), ...sharedPatterns],
patterns: nuxtImportProtections(nuxt), patterns: createImportProtectionPatterns(nuxt, { context: 'nuxt-app' }),
} }
addVitePlugin(() => Object.assign(ImpoundPlugin.vite({ ...config, error: false }), { name: 'nuxt:import-protection' }), { client: false }) addVitePlugin(() => Object.assign(ImpoundPlugin.vite({ ...nuxtProtectionConfig, error: false }), { name: 'nuxt:import-protection' }), { client: false })
addVitePlugin(() => Object.assign(ImpoundPlugin.vite({ ...config, error: true }), { name: 'nuxt:import-protection' }), { server: false }) addVitePlugin(() => Object.assign(ImpoundPlugin.vite({ ...nuxtProtectionConfig, error: true }), { name: 'nuxt:import-protection' }), { server: false })
addWebpackPlugin(() => ImpoundPlugin.webpack(config)) addWebpackPlugin(() => ImpoundPlugin.webpack(nuxtProtectionConfig))
// add resolver for modules used in virtual files // add resolver for modules used in virtual files
addVitePlugin(() => resolveDeepImportsPlugin(nuxt), { client: false }) addVitePlugin(() => resolveDeepImportsPlugin(nuxt), { client: false })

View File

@ -9,12 +9,17 @@ interface ImportProtectionOptions {
exclude?: Array<RegExp | string> exclude?: Array<RegExp | string>
} }
export const nuxtImportProtections = (nuxt: { options: NuxtOptions }, options: { isNitro?: boolean } = {}) => { interface NuxtImportProtectionOptions {
context: 'nuxt-app' | 'nitro-app' | 'shared'
}
export const createImportProtectionPatterns = (nuxt: { options: NuxtOptions }, options: NuxtImportProtectionOptions) => {
const patterns: ImportProtectionOptions['patterns'] = [] const patterns: ImportProtectionOptions['patterns'] = []
const context = contextFlags[options.context]
patterns.push([ patterns.push([
/^(nuxt|nuxt3|nuxt-nightly)$/, /^(nuxt|nuxt3|nuxt-nightly)$/,
'`nuxt`, `nuxt3` or `nuxt-nightly` cannot be imported directly.' + (options.isNitro ? '' : ' Instead, import runtime Nuxt composables from `#app` or `#imports`.'), `\`nuxt\`, or \`nuxt-nightly\` cannot be imported directly in ${context}.` + (options.context === 'nuxt-app' ? ' Instead, import runtime Nuxt composables from `#app` or `#imports`.' : ''),
]) ])
patterns.push([ patterns.push([
@ -26,27 +31,33 @@ export const nuxtImportProtections = (nuxt: { options: NuxtOptions }, options: {
for (const mod of nuxt.options.modules.filter(m => typeof m === 'string')) { for (const mod of nuxt.options.modules.filter(m => typeof m === 'string')) {
patterns.push([ patterns.push([
new RegExp(`^${escapeRE(mod as string)}$`), new RegExp(`^${escapeRE(mod)}$`),
'Importing directly from module entry-points is not allowed.', 'Importing directly from module entry-points is not allowed.',
]) ])
} }
for (const i of [/(^|node_modules\/)@nuxt\/(kit|test-utils)/, /(^|node_modules\/)nuxi/, /(^|node_modules\/)nuxt\/(config|kit|schema)/, 'nitropack']) { for (const i of [/(^|node_modules\/)@nuxt\/(kit|test-utils)/, /(^|node_modules\/)nuxi/, /(^|node_modules\/)nitro(?:pack)?(?:-nightly)?(?:$|\/)(?!(?:dist\/)?runtime|types)/, /(^|node_modules\/)nuxt\/(config|kit|schema)/]) {
patterns.push([i, 'This module cannot be imported' + (options.isNitro ? ' in server runtime.' : ' in the Vue part of your app.')]) patterns.push([i, `This module cannot be imported in ${context}.`])
} }
if (options.isNitro) { if (options.context === 'nitro-app' || options.context === 'shared') {
for (const i of ['#app', /^#build(\/|$)/]) { for (const i of ['#app', /^#build(\/|$)/]) {
patterns.push([i, 'Vue app aliases are not allowed in server runtime.']) patterns.push([i, `Vue app aliases are not allowed in ${context}.`])
} }
} }
if (!options.isNitro) { if (options.context === 'nuxt-app' || options.context === 'shared') {
patterns.push([ patterns.push([
new RegExp(escapeRE(relative(nuxt.options.srcDir, resolve(nuxt.options.srcDir, nuxt.options.serverDir || 'server'))) + '\\/(api|routes|middleware|plugins)\\/'), new RegExp(escapeRE(relative(nuxt.options.srcDir, resolve(nuxt.options.srcDir, nuxt.options.serverDir || 'server'))) + '\\/(api|routes|middleware|plugins)\\/'),
'Importing from server is not allowed in the Vue part of your app.', `Importing from server is not allowed in ${context}.`,
]) ])
} }
return patterns return patterns
} }
const contextFlags = {
'nitro-app': 'server runtime',
'nuxt-app': 'the Vue part of your app',
'shared': 'the #shared directory',
} as const

View File

@ -54,6 +54,8 @@ export default defineNuxtModule<Partial<ImportsOptions>>({
await nuxt.callHook('imports:context', ctx) await nuxt.callHook('imports:context', ctx)
const isNuxtV4 = nuxt.options.future?.compatibilityVersion === 4
// composables/ dirs from all layers // composables/ dirs from all layers
let composablesDirs: string[] = [] let composablesDirs: string[] = []
if (options.scan) { if (options.scan) {
@ -64,6 +66,12 @@ export default defineNuxtModule<Partial<ImportsOptions>>({
} }
composablesDirs.push(resolve(layer.config.srcDir, 'composables')) composablesDirs.push(resolve(layer.config.srcDir, 'composables'))
composablesDirs.push(resolve(layer.config.srcDir, 'utils')) composablesDirs.push(resolve(layer.config.srcDir, 'utils'))
if (isNuxtV4) {
composablesDirs.push(resolve(layer.config.rootDir, 'shared', 'utils'))
composablesDirs.push(resolve(layer.config.rootDir, 'shared', 'types'))
}
for (const dir of (layer.config.imports?.dirs ?? [])) { for (const dir of (layer.config.imports?.dirs ?? [])) {
if (!dir) { if (!dir) {
continue continue

View File

@ -1,7 +1,7 @@
import { normalize } from 'pathe' import { normalize } from 'pathe'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { ImpoundPlugin } from 'impound' import { ImpoundPlugin } from 'impound'
import { nuxtImportProtections } from '../src/core/plugins/import-protection' import { createImportProtectionPatterns } from '../src/core/plugins/import-protection'
import type { NuxtOptions } from '../schema' import type { NuxtOptions } from '../schema'
const testsToTriggerOn = [ const testsToTriggerOn = [
@ -28,7 +28,7 @@ const testsToTriggerOn = [
describe('import protection', () => { describe('import protection', () => {
it.each(testsToTriggerOn)('should protect %s', async (id, importer, isProtected) => { it.each(testsToTriggerOn)('should protect %s', async (id, importer, isProtected) => {
const result = await transformWithImportProtection(id, importer) const result = await transformWithImportProtection(id, importer, 'nuxt-app')
if (!isProtected) { if (!isProtected) {
expect(result).toBeNull() expect(result).toBeNull()
} else { } else {
@ -38,16 +38,16 @@ describe('import protection', () => {
}) })
}) })
const transformWithImportProtection = (id: string, importer: string) => { const transformWithImportProtection = (id: string, importer: string, context: 'nitro-app' | 'nuxt-app' | 'shared') => {
const plugin = ImpoundPlugin.rollup({ const plugin = ImpoundPlugin.rollup({
cwd: '/root', cwd: '/root',
patterns: nuxtImportProtections({ patterns: createImportProtectionPatterns({
options: { options: {
modules: ['some-nuxt-module'], modules: ['some-nuxt-module'],
srcDir: '/root/src/', srcDir: '/root/src/',
serverDir: '/root/src/server', serverDir: '/root/src/server',
} satisfies Partial<NuxtOptions> as NuxtOptions, } satisfies Partial<NuxtOptions> as NuxtOptions,
}), }, { context }),
}) })
return (plugin as any).resolveId.call({ error: () => {} }, id, importer) return (plugin as any).resolveId.call({ error: () => {} }, id, importer)

View File

@ -352,6 +352,11 @@ export default defineUntypedSchema({
*/ */
plugins: 'plugins', plugins: 'plugins',
/**
* The shared directory. This directory is shared between the app and the server.
*/
shared: 'shared',
/** /**
* The directory containing your static files, which will be directly accessible via the Nuxt server * The directory containing your static files, which will be directly accessible via the Nuxt server
* and copied across into your `dist` folder when your app is generated. * and copied across into your `dist` folder when your app is generated.
@ -421,12 +426,13 @@ export default defineUntypedSchema({
*/ */
alias: { alias: {
$resolve: async (val: Record<string, string>, get): Promise<Record<string, string>> => { $resolve: async (val: Record<string, string>, get): Promise<Record<string, string>> => {
const [srcDir, rootDir, assetsDir, publicDir, buildDir] = await Promise.all([get('srcDir'), get('rootDir'), get('dir.assets'), get('dir.public'), get('buildDir')]) as [string, string, string, string, string] const [srcDir, rootDir, assetsDir, publicDir, buildDir, sharedDir] = await Promise.all([get('srcDir'), get('rootDir'), get('dir.assets'), get('dir.public'), get('buildDir'), get('dir.shared')]) as [string, string, string, string, string, string]
return { return {
'~': srcDir, '~': srcDir,
'@': srcDir, '@': srcDir,
'~~': rootDir, '~~': rootDir,
'@@': rootDir, '@@': rootDir,
'#shared': resolve(rootDir, sharedDir),
[basename(assetsDir)]: resolve(srcDir, assetsDir), [basename(assetsDir)]: resolve(srcDir, assetsDir),
[basename(publicDir)]: resolve(srcDir, publicDir), [basename(publicDir)]: resolve(srcDir, publicDir),
'#build': buildDir, '#build': buildDir,

View File

@ -8,6 +8,7 @@ import type { ViteConfig } from '@nuxt/schema'
import type { PackageJson } from 'pkg-types' import type { PackageJson } from 'pkg-types'
import defu from 'defu' import defu from 'defu'
import type { Nitro } from 'nitropack' import type { Nitro } from 'nitropack'
import escapeStringRegexp from 'escape-string-regexp'
import type { ViteBuildContext } from './vite' import type { ViteBuildContext } from './vite'
import { createViteLogger } from './utils/logger' import { createViteLogger } from './utils/logger'
import { initViteNodeServer } from './vite-node' import { initViteNodeServer } from './vite-node'
@ -81,7 +82,12 @@ export async function buildServer (ctx: ViteBuildContext) {
ssr: true, ssr: true,
rollupOptions: { rollupOptions: {
input: { server: entry }, input: { server: entry },
external: ['#internal/nitro', '#internal/nuxt/paths'], external: [
'#internal/nitro',
'#internal/nuxt/paths',
'#shared',
new RegExp('^' + escapeStringRegexp(withTrailingSlash(resolve(ctx.nuxt.options.rootDir, ctx.nuxt.options.dir.shared)))),
],
output: { output: {
entryFileNames: '[name].mjs', entryFileNames: '[name].mjs',
format: 'module', format: 'module',

View File

@ -1,9 +1,13 @@
import type { ExternalsOptions } from 'externality' import type { ExternalsOptions } from 'externality'
import { ExternalsDefaults, isExternal } from 'externality' import { ExternalsDefaults, isExternal } from 'externality'
import type { ViteDevServer } from 'vite' import type { ViteDevServer } from 'vite'
import escapeStringRegexp from 'escape-string-regexp'
import { withTrailingSlash } from 'ufo'
import type { Nuxt } from 'nuxt/schema'
import { resolve } from 'pathe'
import { toArray } from '.' import { toArray } from '.'
export function createIsExternal (viteServer: ViteDevServer, rootDir: string, modulesDirs?: string[]) { export function createIsExternal (viteServer: ViteDevServer, nuxt: Nuxt) {
const externalOpts: ExternalsOptions = { const externalOpts: ExternalsOptions = {
inline: [ inline: [
/virtual:/, /virtual:/,
@ -16,15 +20,17 @@ export function createIsExternal (viteServer: ViteDevServer, rootDir: string, mo
), ),
], ],
external: [ external: [
'#shared',
new RegExp('^' + escapeStringRegexp(withTrailingSlash(resolve(nuxt.options.rootDir, nuxt.options.dir.shared)))),
...(viteServer.config.ssr.external as string[]) || [], ...(viteServer.config.ssr.external as string[]) || [],
/node_modules/, /node_modules/,
], ],
resolve: { resolve: {
modules: modulesDirs, modules: nuxt.options.modulesDir,
type: 'module', type: 'module',
extensions: ['.ts', '.js', '.json', '.vue', '.mjs', '.jsx', '.tsx', '.wasm'], extensions: ['.ts', '.js', '.json', '.vue', '.mjs', '.jsx', '.tsx', '.wasm'],
}, },
} }
return (id: string) => isExternal(id, rootDir, externalOpts) return (id: string) => isExternal(id, nuxt.options.rootDir, externalOpts)
} }

View File

@ -140,7 +140,7 @@ function createViteNodeApp (ctx: ViteBuildContext, invalidates: Set<string> = ne
}, },
}) })
const isExternal = createIsExternal(viteServer, ctx.nuxt.options.rootDir, ctx.nuxt.options.modulesDir) const isExternal = createIsExternal(viteServer, ctx.nuxt)
node.shouldExternalize = async (id: string) => { node.shouldExternalize = async (id: string) => {
const result = await isExternal(id) const result = await isExternal(id)
if (result?.external) { if (result?.external) {

View File

@ -1,4 +1,4 @@
import { isAbsolute } from 'pathe' import { isAbsolute, resolve } from 'pathe'
import ForkTSCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin' import ForkTSCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin'
import { logger } from '@nuxt/kit' import { logger } from '@nuxt/kit'
import type { WebpackConfigContext } from '../utils/config' import type { WebpackConfigContext } from '../utils/config'
@ -53,7 +53,11 @@ function serverStandalone (ctx: WebpackConfigContext) {
'#', '#',
...ctx.options.build.transpile, ...ctx.options.build.transpile,
] ]
const external = ['#internal/nitro'] const external = [
'#internal/nitro',
'#shared',
resolve(ctx.nuxt.options.rootDir, ctx.nuxt.options.dir.shared),
]
if (!ctx.nuxt.options.dev) { if (!ctx.nuxt.options.dev) {
external.push('#internal/nuxt/paths') external.push('#internal/nuxt/paths')
} }