From 18d547d5a5d810ce2c11fbcc83a1841d617f85ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Est=C3=A9ban?= Date: Sat, 2 Nov 2024 22:13:41 +0100 Subject: [PATCH] feat(schema,nuxt): add `shared/` folder and `#shared` alias (#28682) --- packages/nuxt/src/core/nitro.ts | 23 +++++++++++++-- packages/nuxt/src/core/nuxt.ts | 27 ++++++++++++----- .../src/core/plugins/import-protection.ts | 29 +++++++++++++------ packages/nuxt/src/imports/module.ts | 8 +++++ packages/nuxt/test/import-protection.test.ts | 10 +++---- packages/schema/src/config/common.ts | 8 ++++- packages/vite/src/server.ts | 8 ++++- packages/vite/src/utils/external.ts | 12 ++++++-- packages/vite/src/vite-node.ts | 2 +- packages/webpack/src/configs/server.ts | 8 +++-- 10 files changed, 102 insertions(+), 33 deletions(-) diff --git a/packages/nuxt/src/core/nitro.ts b/packages/nuxt/src/core/nitro.ts index 2d01db8b9a..a3a156fc48 100644 --- a/packages/nuxt/src/core/nitro.ts +++ b/packages/nuxt/src/core/nitro.ts @@ -17,7 +17,7 @@ import { version as nuxtVersion } from '../../package.json' import { distDir } from '../dirs' import { toArray } from '../utils' 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' const logLevelMapReverse = { @@ -49,6 +49,8 @@ export async function initNitro (nuxt: Nuxt & { _nitro?: Nitro }) { .map(m => m.entryPath!), ) + const isNuxtV4 = nuxt.options.future?.compatibilityVersion === 4 + const nitroConfig: NitroConfig = defu(nuxt.options.nitro, { debug: nuxt.options.debug, rootDir: nuxt.options.rootDir, @@ -66,6 +68,12 @@ export async function initNitro (nuxt: Nuxt & { _nitro?: Nitro }) { }, imports: { autoImport: nuxt.options.imports.autoImport as boolean, + dirs: isNuxtV4 + ? [ + resolve(nuxt.options.rootDir, 'shared', 'utils'), + resolve(nuxt.options.rootDir, 'shared', 'types'), + ] + : [], imports: [ { as: '__buildAssetsURL', @@ -362,11 +370,20 @@ export async function initNitro (nuxt: Nuxt & { _nitro?: Nitro }) { // Register nuxt protection patterns nitroConfig.rollupConfig!.plugins = await 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( ImpoundPlugin.rollup({ cwd: nuxt.options.rootDir, - patterns: nuxtImportProtections(nuxt, { isNitro: true }), - exclude: [/core[\\/]runtime[\\/]nitro[\\/]renderer/], + include: sharedPatterns, + patterns: createImportProtectionPatterns(nuxt, { context: 'shared' }), + }), + ImpoundPlugin.rollup({ + cwd: nuxt.options.rootDir, + patterns: createImportProtectionPatterns(nuxt, { context: 'nitro-app' }), + exclude: [/core[\\/]runtime[\\/]nitro[\\/]renderer/, ...sharedPatterns], }), ) diff --git a/packages/nuxt/src/core/nuxt.ts b/packages/nuxt/src/core/nuxt.ts index 2693d6ee14..6820423944 100644 --- a/packages/nuxt/src/core/nuxt.ts +++ b/packages/nuxt/src/core/nuxt.ts @@ -18,7 +18,6 @@ import type { DateString } from 'compatx' import escapeRE from 'escape-string-regexp' import { withTrailingSlash, withoutLeadingSlash } from 'ufo' import { ImpoundPlugin } from 'impound' -import type { ImpoundOptions } from 'impound' import defu from 'defu' import { gt, satisfies } from 'semver' import { hasTTY, isCI } from 'std-env' @@ -32,7 +31,7 @@ import { distDir, pkgDir } from '../dirs' import { version } from '../../package.json' import { scriptsStubsPreset } from '../imports/presets' import { resolveTypePath } from './utils/types' -import { nuxtImportProtections } from './plugins/import-protection' +import { createImportProtectionPatterns } from './plugins/import-protection' import { UnctxTransformPlugin } from './plugins/unctx' import { TreeShakeComposablesPlugin } from './plugins/tree-shake' import { DevOnlyPlugin } from './plugins/dev-only' @@ -249,16 +248,28 @@ async function initNuxt (nuxt: Nuxt) { // Add plugin normalization plugin 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 - const config: ImpoundOptions = { + const nuxtProtectionConfig = { cwd: nuxt.options.rootDir, // Exclude top-level resolutions by plugins - exclude: [join(nuxt.options.srcDir, 'index.html')], - patterns: nuxtImportProtections(nuxt), + exclude: [relative(nuxt.options.rootDir, join(nuxt.options.srcDir, 'index.html')), ...sharedPatterns], + 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({ ...config, error: true }), { name: 'nuxt:import-protection' }), { server: false }) - addWebpackPlugin(() => ImpoundPlugin.webpack(config)) + addVitePlugin(() => Object.assign(ImpoundPlugin.vite({ ...nuxtProtectionConfig, error: false }), { name: 'nuxt:import-protection' }), { client: false }) + addVitePlugin(() => Object.assign(ImpoundPlugin.vite({ ...nuxtProtectionConfig, error: true }), { name: 'nuxt:import-protection' }), { server: false }) + addWebpackPlugin(() => ImpoundPlugin.webpack(nuxtProtectionConfig)) // add resolver for modules used in virtual files addVitePlugin(() => resolveDeepImportsPlugin(nuxt), { client: false }) diff --git a/packages/nuxt/src/core/plugins/import-protection.ts b/packages/nuxt/src/core/plugins/import-protection.ts index 22634f09e8..497d7d523d 100644 --- a/packages/nuxt/src/core/plugins/import-protection.ts +++ b/packages/nuxt/src/core/plugins/import-protection.ts @@ -9,12 +9,17 @@ interface ImportProtectionOptions { exclude?: Array } -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 context = contextFlags[options.context] patterns.push([ /^(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([ @@ -26,27 +31,33 @@ export const nuxtImportProtections = (nuxt: { options: NuxtOptions }, options: { for (const mod of nuxt.options.modules.filter(m => typeof m === 'string')) { patterns.push([ - new RegExp(`^${escapeRE(mod as string)}$`), + new RegExp(`^${escapeRE(mod)}$`), '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']) { - patterns.push([i, 'This module cannot be imported' + (options.isNitro ? ' in server runtime.' : ' in the Vue part of your app.')]) + 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 in ${context}.`]) } - if (options.isNitro) { + if (options.context === 'nitro-app' || options.context === 'shared') { 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([ 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 } + +const contextFlags = { + 'nitro-app': 'server runtime', + 'nuxt-app': 'the Vue part of your app', + 'shared': 'the #shared directory', +} as const diff --git a/packages/nuxt/src/imports/module.ts b/packages/nuxt/src/imports/module.ts index af728fb6db..33aad9a885 100644 --- a/packages/nuxt/src/imports/module.ts +++ b/packages/nuxt/src/imports/module.ts @@ -54,6 +54,8 @@ export default defineNuxtModule>({ await nuxt.callHook('imports:context', ctx) + const isNuxtV4 = nuxt.options.future?.compatibilityVersion === 4 + // composables/ dirs from all layers let composablesDirs: string[] = [] if (options.scan) { @@ -64,6 +66,12 @@ export default defineNuxtModule>({ } composablesDirs.push(resolve(layer.config.srcDir, 'composables')) 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 ?? [])) { if (!dir) { continue diff --git a/packages/nuxt/test/import-protection.test.ts b/packages/nuxt/test/import-protection.test.ts index 110f47dfb1..58d351317e 100644 --- a/packages/nuxt/test/import-protection.test.ts +++ b/packages/nuxt/test/import-protection.test.ts @@ -1,7 +1,7 @@ import { normalize } from 'pathe' import { describe, expect, it } from 'vitest' 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' const testsToTriggerOn = [ @@ -28,7 +28,7 @@ const testsToTriggerOn = [ describe('import protection', () => { 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) { expect(result).toBeNull() } 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({ cwd: '/root', - patterns: nuxtImportProtections({ + patterns: createImportProtectionPatterns({ options: { modules: ['some-nuxt-module'], srcDir: '/root/src/', serverDir: '/root/src/server', } satisfies Partial as NuxtOptions, - }), + }, { context }), }) return (plugin as any).resolveId.call({ error: () => {} }, id, importer) diff --git a/packages/schema/src/config/common.ts b/packages/schema/src/config/common.ts index ff8d247b02..94094024ea 100644 --- a/packages/schema/src/config/common.ts +++ b/packages/schema/src/config/common.ts @@ -352,6 +352,11 @@ export default defineUntypedSchema({ */ 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 * and copied across into your `dist` folder when your app is generated. @@ -421,12 +426,13 @@ export default defineUntypedSchema({ */ alias: { $resolve: async (val: Record, get): Promise> => { - 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 { '~': srcDir, '@': srcDir, '~~': rootDir, '@@': rootDir, + '#shared': resolve(rootDir, sharedDir), [basename(assetsDir)]: resolve(srcDir, assetsDir), [basename(publicDir)]: resolve(srcDir, publicDir), '#build': buildDir, diff --git a/packages/vite/src/server.ts b/packages/vite/src/server.ts index 39d4e90132..719647c35e 100644 --- a/packages/vite/src/server.ts +++ b/packages/vite/src/server.ts @@ -8,6 +8,7 @@ import type { ViteConfig } from '@nuxt/schema' import type { PackageJson } from 'pkg-types' import defu from 'defu' import type { Nitro } from 'nitropack' +import escapeStringRegexp from 'escape-string-regexp' import type { ViteBuildContext } from './vite' import { createViteLogger } from './utils/logger' import { initViteNodeServer } from './vite-node' @@ -81,7 +82,12 @@ export async function buildServer (ctx: ViteBuildContext) { ssr: true, rollupOptions: { 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: { entryFileNames: '[name].mjs', format: 'module', diff --git a/packages/vite/src/utils/external.ts b/packages/vite/src/utils/external.ts index f8f1320a6f..c6889cb3a1 100644 --- a/packages/vite/src/utils/external.ts +++ b/packages/vite/src/utils/external.ts @@ -1,9 +1,13 @@ import type { ExternalsOptions } from 'externality' import { ExternalsDefaults, isExternal } from 'externality' 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 '.' -export function createIsExternal (viteServer: ViteDevServer, rootDir: string, modulesDirs?: string[]) { +export function createIsExternal (viteServer: ViteDevServer, nuxt: Nuxt) { const externalOpts: ExternalsOptions = { inline: [ /virtual:/, @@ -16,15 +20,17 @@ export function createIsExternal (viteServer: ViteDevServer, rootDir: string, mo ), ], external: [ + '#shared', + new RegExp('^' + escapeStringRegexp(withTrailingSlash(resolve(nuxt.options.rootDir, nuxt.options.dir.shared)))), ...(viteServer.config.ssr.external as string[]) || [], /node_modules/, ], resolve: { - modules: modulesDirs, + modules: nuxt.options.modulesDir, type: 'module', 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) } diff --git a/packages/vite/src/vite-node.ts b/packages/vite/src/vite-node.ts index a6eba23ec0..bb63c257b9 100644 --- a/packages/vite/src/vite-node.ts +++ b/packages/vite/src/vite-node.ts @@ -140,7 +140,7 @@ function createViteNodeApp (ctx: ViteBuildContext, invalidates: Set = ne }, }) - const isExternal = createIsExternal(viteServer, ctx.nuxt.options.rootDir, ctx.nuxt.options.modulesDir) + const isExternal = createIsExternal(viteServer, ctx.nuxt) node.shouldExternalize = async (id: string) => { const result = await isExternal(id) if (result?.external) { diff --git a/packages/webpack/src/configs/server.ts b/packages/webpack/src/configs/server.ts index 19867d0436..aac7c57ccd 100644 --- a/packages/webpack/src/configs/server.ts +++ b/packages/webpack/src/configs/server.ts @@ -1,4 +1,4 @@ -import { isAbsolute } from 'pathe' +import { isAbsolute, resolve } from 'pathe' import ForkTSCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin' import { logger } from '@nuxt/kit' import type { WebpackConfigContext } from '../utils/config' @@ -53,7 +53,11 @@ function serverStandalone (ctx: WebpackConfigContext) { '#', ...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) { external.push('#internal/nuxt/paths') }