diff --git a/apps/blog-next/package.json b/apps/blog-next/package.json index 69593a34..cb7147c5 100644 --- a/apps/blog-next/package.json +++ b/apps/blog-next/package.json @@ -32,7 +32,6 @@ "@holo-js/core": "workspace:*", "@holo-js/db": "workspace:*", "@holo-js/db-sqlite": "workspace:*", - "@holo-js/events": "workspace:*", "@holo-js/flux": "workspace:*", "@holo-js/flux-react": "workspace:*", "@holo-js/forms": "workspace:*", diff --git a/apps/blog-nuxt/package.json b/apps/blog-nuxt/package.json index 8075392e..4ae7d2cf 100644 --- a/apps/blog-nuxt/package.json +++ b/apps/blog-nuxt/package.json @@ -33,7 +33,6 @@ "@holo-js/core": "workspace:*", "@holo-js/db": "workspace:*", "@holo-js/db-sqlite": "workspace:*", - "@holo-js/events": "workspace:*", "@holo-js/flux": "workspace:*", "@holo-js/flux-vue": "workspace:*", "@holo-js/forms": "workspace:*", diff --git a/apps/blog-sveltekit/package.json b/apps/blog-sveltekit/package.json index 4ed4b386..0f4ea0a1 100644 --- a/apps/blog-sveltekit/package.json +++ b/apps/blog-sveltekit/package.json @@ -32,7 +32,6 @@ "@holo-js/core": "workspace:*", "@holo-js/db": "workspace:*", "@holo-js/db-sqlite": "workspace:*", - "@holo-js/events": "workspace:*", "@holo-js/flux": "workspace:*", "@holo-js/flux-svelte": "workspace:*", "@holo-js/forms": "workspace:*", diff --git a/bun.lock b/bun.lock index 932e9b2f..c7465c57 100644 --- a/bun.lock +++ b/bun.lock @@ -129,7 +129,6 @@ "@holo-js/core": "workspace:*", "@holo-js/db": "workspace:*", "@holo-js/db-sqlite": "workspace:*", - "@holo-js/events": "workspace:*", "@holo-js/flux": "workspace:*", "@holo-js/flux-react": "workspace:*", "@holo-js/forms": "workspace:*", diff --git a/packages/adapter-nuxt/src/module.ts b/packages/adapter-nuxt/src/module.ts index d0c24bd0..9dc75687 100644 --- a/packages/adapter-nuxt/src/module.ts +++ b/packages/adapter-nuxt/src/module.ts @@ -1,4 +1,5 @@ import { access, mkdir, readdir, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' import { basename, dirname, extname, relative, resolve } from 'node:path' import { addImports, @@ -77,6 +78,17 @@ type StorageS3Module = { } const MODEL_FILE_EXTENSIONS = new Set(['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs']) +const HOLO_PACKAGE_SCOPE = '@holo-js/' +const CLIENT_OPTIMIZE_DEPS = [ + { + packageName: `${HOLO_PACKAGE_SCOPE}forms`, + include: `${HOLO_PACKAGE_SCOPE}forms > ${HOLO_PACKAGE_SCOPE}validation > valibot`, + }, + { + packageName: `${HOLO_PACKAGE_SCOPE}validation`, + include: `${HOLO_PACKAGE_SCOPE}validation > valibot`, + }, +] as const function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') @@ -109,6 +121,18 @@ function hasModuleNotFoundCode(error: unknown, expectedSpecifier: string): boole return false } +function isModuleResolutionFailure(error: unknown): boolean { + if (!error || typeof error !== 'object') { + return false + } + + return 'code' in error + && ( + (error as { code?: unknown }).code === 'MODULE_NOT_FOUND' + || (error as { code?: unknown }).code === 'ERR_MODULE_NOT_FOUND' + ) +} + interface NuxtHookContext { hook: ( name: string, @@ -116,6 +140,16 @@ interface NuxtHookContext { ) => void } +type ViteOptimizeDepsOptions = { + include?: string[] + [key: string]: unknown +} + +type NuxtViteOptions = { + optimizeDeps?: ViteOptimizeDepsOptions + [key: string]: unknown +} + interface NuxtOptionsWithNitro { nitro: { storage: Record @@ -137,6 +171,7 @@ interface NuxtOptionsWithNitro { [key: string]: unknown } build: { transpile: string[] } + vite?: NuxtViteOptions srcDir: string rootDir?: string _holoStorageModuleOptions?: StorageModuleOptions @@ -146,6 +181,40 @@ interface NuxtOptionsWithNitro { _holoTypesRegistered?: boolean } +function resolveClientOptimizeDeps(rootDir: string): string[] { + const projectRequire = createRequire(resolve(rootDir, 'package.json')) + const deps: string[] = [] + + for (const { packageName, include } of CLIENT_OPTIMIZE_DEPS) { + try { + projectRequire.resolve(packageName) + deps.push(include) + } catch (error) { + /* v8 ignore next 3 -- unexpected resolver failures should fail module setup */ + if (!isModuleResolutionFailure(error)) { + throw error + } + } + } + + return deps +} + +function addViteOptimizeDeps(opts: NuxtOptionsWithNitro, deps: readonly string[]): void { + if (deps.length === 0) { + return + } + + opts.vite = opts.vite || {} + opts.vite.optimizeDeps = opts.vite.optimizeDeps || {} + opts.vite.optimizeDeps.include = [ + ...new Set([ + ...(opts.vite.optimizeDeps.include || []), + ...deps, + ]), + ] +} + /* v8 ignore next 15 -- optional-package absence is validated in published-package integration, not in this monorepo test graph */ async function importOptionalStorageModule(): Promise { try { @@ -278,6 +347,7 @@ export default defineNuxtModule({ const rootDir = opts.rootDir ?? opts.srcDir ?? process.cwd() const sourceDir = opts.srcDir ?? rootDir const modelRegistryTypesPath = resolve(rootDir, '.holo-js/generated/model-registry.d.ts') + addViteOptimizeDeps(opts, resolveClientOptimizeDeps(rootDir)) const loaded = await loadConfigDirectory(rootDir, { preferCache: process.env.NODE_ENV === 'production', processEnv: process.env, @@ -399,9 +469,12 @@ export default defineNuxtModule({ }) export const moduleInternals = { + addViteOptimizeDeps, hasModuleNotFoundCode, hasLoadedConfigFile, importOptionalStorageS3Module, + isModuleResolutionFailure, + resolveClientOptimizeDeps, } export const adapterNuxtInternals = { diff --git a/packages/adapter-nuxt/tests/setup.test.ts b/packages/adapter-nuxt/tests/setup.test.ts index 689ed5dd..f2333e6c 100644 --- a/packages/adapter-nuxt/tests/setup.test.ts +++ b/packages/adapter-nuxt/tests/setup.test.ts @@ -29,6 +29,13 @@ type NuxtHarnessOptions = { build: { transpile: string[] } + vite?: { + optimizeDeps?: { + include?: string[] + [key: string]: unknown + } + [key: string]: unknown + } nitro?: { storage?: Record } @@ -172,6 +179,10 @@ function getHoloStorageRuntimeConfig(nuxt: NuxtHarness): Record return nuxt.options.runtimeConfig?.holoStorage as Record | undefined } +function getOptimizeDepsInclude(nuxt: NuxtHarness): readonly string[] { + return nuxt.options.vite?.optimizeDeps?.include ?? [] +} + async function createProject(): Promise { const root = await mkdtemp(join(tmpdir(), 'holo-storage-module-')) tempDirs.push(root) @@ -179,6 +190,12 @@ async function createProject(): Promise { return root } +async function linkHoloPackage(root: string, name: string): Promise { + const scopedPackageDir = join(root, 'node_modules/@holo-js') + await mkdir(scopedPackageDir, { recursive: true }) + await symlink(resolve(packageDir, `../${name}`), join(scopedPackageDir, name)) +} + async function loadAdapterModule() { vi.resetModules() @@ -202,6 +219,7 @@ async function loadAdapterModule() { return { module: mod.default, + moduleInternals: mod.moduleInternals, addImports, addServerImportsDir, addServerHandler, @@ -358,6 +376,64 @@ describe('@holo-js/adapter-nuxt module setup', () => { expect(publishedPlugin).toContain('from "nitropack/runtime/config"') }, 60000) + it('resolves installed Holo client optimizer dependencies from the project root', async () => { + const root = await createProject() + const { moduleInternals } = await loadAdapterModule() + + expect(moduleInternals.resolveClientOptimizeDeps(root)).toEqual([]) + expect(moduleInternals.isModuleResolutionFailure(null)).toBe(false) + expect(moduleInternals.isModuleResolutionFailure({ code: 'ERR_PACKAGE_PATH_NOT_EXPORTED' })).toBe(false) + expect(moduleInternals.isModuleResolutionFailure({ code: 'MODULE_NOT_FOUND' })).toBe(true) + expect(moduleInternals.isModuleResolutionFailure({ code: 'ERR_MODULE_NOT_FOUND' })).toBe(true) + + const nuxt = createNuxtHarness(root) + moduleInternals.addViteOptimizeDeps(nuxt.options as never, []) + expect(nuxt.options.vite).toBeUndefined() + + moduleInternals.addViteOptimizeDeps(nuxt.options as never, ['@holo-js/validation > valibot']) + expect(getOptimizeDepsInclude(nuxt)).toEqual(['@holo-js/validation > valibot']) + + await linkHoloPackage(root, 'validation') + expect(moduleInternals.resolveClientOptimizeDeps(root)).toEqual([ + '@holo-js/validation > valibot', + ]) + + await linkHoloPackage(root, 'forms') + expect(moduleInternals.resolveClientOptimizeDeps(root)).toEqual([ + '@holo-js/forms > @holo-js/validation > valibot', + '@holo-js/validation > valibot', + ]) + }) + + it('merges Holo client optimizer dependencies without replacing user Vite options', async () => { + const root = await createProject() + await mkdir(join(root, '.holo-js/generated'), { recursive: true }) + await writeFile(join(root, '.holo-js/generated/model-registry.d.ts'), 'export {}\n', 'utf8') + await linkHoloPackage(root, 'forms') + await linkHoloPackage(root, 'validation') + + const { module } = await loadAdapterModule() + const nuxt = createNuxtHarness(root) + nuxt.options.vite = { + optimizeDeps: { + include: [ + 'vue', + '@holo-js/validation > valibot', + ], + exclude: ['vue-demi'], + }, + } + + await module.setup({}, nuxt as never) + + expect(getOptimizeDepsInclude(nuxt)).toEqual([ + 'vue', + '@holo-js/validation > valibot', + '@holo-js/forms > @holo-js/validation > valibot', + ]) + expect(nuxt.options.vite.optimizeDeps?.exclude).toEqual(['vue-demi']) + }, 30000) + it('loads storage config files from the project root and wires nitro/runtime state', async () => { const root = await createProject() await writeFile(join(root, '.env'), 'STORAGE_DEFAULT_DISK=media\n', 'utf8')