diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c1fc874..deaf35d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,7 +32,7 @@ jobs: - run: npm install - - run: npm run release -- --ci + - run: npm run release -- --ci --preRelease=next env: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/src/client/config.ts b/src/client/config.ts index 090aa46..60b4762 100644 --- a/src/client/config.ts +++ b/src/client/config.ts @@ -77,11 +77,42 @@ export function configHook( input: userConfig.build?.rolldownOptions?.input ?? userConfig.build?.rollupOptions?.input ?? - options.entrypoints.map((entrypoint) => join(userConfig.root || '', entrypoint)), + options.entrypoints, }, }, } + /** + * When server-side entrypoints are declared, configure the SSR build + * environment so it mirrors the client setup: raw array input, manifest + * emitted alongside the output. `loadServerModule` reads that manifest + * at runtime to resolve a source path to the bundled file. + * + * Each field defers to a user/plugin-supplied value when present so + * other plugins contributing to `environments.ssr` cooperate rather + * than collide. + */ + if (options.serverEntrypoints.length > 0) { + const userSsrBuild = userConfig.environments?.ssr?.build + config.environments = { + ssr: { + build: { + ssr: userSsrBuild?.ssr ?? true, + emptyOutDir: userSsrBuild?.emptyOutDir ?? true, + emitAssets: userSsrBuild?.emitAssets ?? false, + manifest: userSsrBuild?.manifest ?? true, + outDir: userSsrBuild?.outDir ?? join(options.buildDirectory, 'server'), + rolldownOptions: { + input: + userSsrBuild?.rolldownOptions?.input ?? + (userSsrBuild as any)?.rollupOptions?.input ?? + options.serverEntrypoints, + }, + }, + }, + } + } + return config } diff --git a/src/client/main.ts b/src/client/main.ts index b0c5efd..4313c54 100644 --- a/src/client/main.ts +++ b/src/client/main.ts @@ -29,6 +29,7 @@ export default function adonisjs(options: PluginOptions): PluginOption[] { assetsUrl: '/assets', buildDirectory: 'public/assets', reload: ['./resources/views/**/*.edge'], + serverEntrypoints: [] as string[], }, options ) diff --git a/src/client/reload.ts b/src/client/reload.ts index da3e739..fcadfd9 100644 --- a/src/client/reload.ts +++ b/src/client/reload.ts @@ -9,7 +9,7 @@ import path from 'node:path' import picomatch from 'picomatch' -import type { Plugin } from 'vite' +import { type Plugin, normalizePath } from 'vite' export interface ReloadOptions { delay?: number @@ -47,8 +47,15 @@ export function reload(patterns: string | string[], options: ReloadOptions = {}) name: 'adonisjs:reload', apply: 'serve', configureServer(server) { - const root = server.config.root - const absolutePatterns = patternList.map((pattern) => path.posix.join(root, pattern)) + /** + * Vite's `normalizePath` converts platform-native separators to + * POSIX so glob patterns and chokidar-emitted file paths line up + * on Windows. + */ + const root = normalizePath(server.config.root) + const absolutePatterns = patternList.map((pattern) => + path.posix.join(root, normalizePath(pattern)) + ) const isMatch = picomatch(absolutePatterns) /** @@ -59,7 +66,7 @@ export function reload(patterns: string | string[], options: ReloadOptions = {}) server.watcher.add(baseDirs) const trigger = (file: string) => { - if (!isMatch(file)) { + if (!isMatch(normalizePath(file))) { return } diff --git a/src/client/resolve_assets.ts b/src/client/resolve_assets.ts index 1590eb0..6152692 100644 --- a/src/client/resolve_assets.ts +++ b/src/client/resolve_assets.ts @@ -9,24 +9,26 @@ import type { Plugin } from 'vite' import { globSync } from 'tinyglobby' -import { readFileSync, statSync, writeFileSync } from 'node:fs' -import { basename, isAbsolute, join, normalize, resolve } from 'node:path' +import { readFileSync, statSync } from 'node:fs' +import { basename, isAbsolute, resolve } from 'node:path' + import type { PluginOptions } from './types.ts' const GLOB_CHARS_REGEX = /[*?{}[\]]/ /** - * Returns a pair of plugins that: + * Returns a plugin that emits user-supplied files into the build output. + * + * `chunks` are passed to Vite's `emitFile({ type: 'chunk' })` after glob + * expansion. They are processed by the bundler and surface in the manifest + * with hashed filenames. * - * 1. Emits user-supplied files into the build output. `chunks` are passed to - * Vite's `emitFile({ type: 'chunk' })` after glob expansion. `assets` are - * emitted as raw `type: 'asset'` files (no glob expansion — exact paths - * only) so the original filename hashing applies but the source content - * is preserved verbatim. - * 2. Rewrites the manifest after Vite writes it so that emitted asset - * entries are keyed by their original source path (instead of Vite's - * synthetic `_.` key). Lets templates resolve assets via - * `vite.assetPath('resources/images/logo.png')`. + * `assets` are emitted as raw `type: 'asset'` files (no glob expansion — + * exact paths only) so the original source content is preserved verbatim. + * The `originalFileName` field tells Vite to use the source path as the + * manifest key, so templates can resolve them via + * `vite.assetPath('resources/images/logo.png')` without any post-build + * manifest rewriting. * * Returns an empty array (no-op) when neither chunks nor assets are * configured. @@ -47,11 +49,6 @@ export function resolveAssets(input: PluginOptions['assets']): Plugin[] { } } - const assetRefToSource = new Map() - const toAbsolute = (file: string) => (isAbsolute(file) ? file : resolve(root, file)) - - let manifestFileName = '.vite/manifest.json' - let manifestEnabled = true let root = process.cwd() return [ @@ -62,70 +59,21 @@ export function resolveAssets(input: PluginOptions['assets']): Plugin[] { root = config.root }, buildStart() { - const chunkPatterns = chunks.map(toAbsolute) - for (const file of globSync(chunkPatterns)) { + for (const file of globSync(chunks, { cwd: root, absolute: true })) { if (statSync(file).isFile()) { this.emitFile({ type: 'chunk', id: file }) } } for (const file of assets) { - const absolute = toAbsolute(file) - const refId = this.emitFile({ + const absolute = isAbsolute(file) ? file : resolve(root, file) + this.emitFile({ type: 'asset', name: basename(file), + originalFileName: file, source: readFileSync(absolute), }) - assetRefToSource.set(refId, normalize(file)) - } - }, - }, - { - name: 'adonisjs:resolve-assets:manifest', - apply: 'build', - enforce: 'post', - configResolved(config) { - const manifest = config.build.manifest - manifestEnabled = manifest !== false - manifestFileName = typeof manifest === 'string' ? manifest : '.vite/manifest.json' - }, - writeBundle(options) { - if (!manifestEnabled || assetRefToSource.size === 0 || !options.dir) { - return } - - const manifestPath = join(options.dir, manifestFileName) - const manifestData = JSON.parse(readFileSync(manifestPath, 'utf-8')) - - for (const [refId, sourcePath] of assetRefToSource) { - const outputFileName = this.getFileName(refId) - - /** - * Vite keys emitted assets with their hashed filename prefixed by - * an underscore. Find that synthetic key, copy its entry under - * the source path, then drop the synthetic one. - */ - const wrongKey = Object.keys(manifestData).find( - (key) => manifestData[key].file === outputFileName - ) - - if (wrongKey) { - manifestData[sourcePath] = { - ...manifestData[wrongKey], - file: outputFileName, - src: sourcePath, - } - delete manifestData[wrongKey] - } - } - - writeFileSync(manifestPath, JSON.stringify(manifestData, null, 2)) - - /** - * Reset state so a subsequent build (e.g. via createBuilder running - * multiple environments) starts clean. - */ - assetRefToSource.clear() }, }, ] diff --git a/src/client/types.ts b/src/client/types.ts index 8e7d46b..b5fae19 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -35,6 +35,16 @@ export interface PluginOptions { */ buildDirectory?: string + /** + * Server-side entrypoints bundled into the SSR build output. Each + * entry is emitted as a single bundle (no hash, no shared chunks) + * under `/server/.js` and becomes loadable + * through `vite.loadServerModule()` at runtime. + * + * Paths are relative to the project root. + */ + serverEntrypoints?: string[] + /** * Additional files to include in the build that are not imported by the * entrypoints. diff --git a/src/hooks/build_hook.ts b/src/hooks/build_hook.ts index dbbcd84..d01c54d 100644 --- a/src/hooks/build_hook.ts +++ b/src/hooks/build_hook.ts @@ -17,7 +17,24 @@ import { createBuilder } from 'vite' * The hook is responsible for launching a Vite multi-build process. */ export default hooks.buildStarting(async (parent) => { + /** + * Vite's CLI sets NODE_ENV to 'production' automatically when running + * `vite build`, but the programmatic `createBuilder` API does not. + * Without this, framework plugins (React, Vue, MDX, …) emit dev-only + * code paths in the production bundle. + * + * See https://github.com/remix-run/remix/issues/4081 + */ + process.env.NODE_ENV = 'production' + parent.ui.logger.info('building assets with vite') - const builder = await createBuilder({}, null) + + /** + * Force multi-environment builder mode (`useLegacyBuilder = false`). + * Vite's default builder builds every declared environment when no + * plugin contributes a custom `buildApp`, which is what we want for + * apps that declare both client `entrypoints` and `serverEntrypoints`. + */ + const builder = await createBuilder({}, false) await builder.buildApp() }) diff --git a/src/server_modules/bundled_module_resolver.ts b/src/server_modules/bundled_module_resolver.ts new file mode 100644 index 0000000..4d576b3 --- /dev/null +++ b/src/server_modules/bundled_module_resolver.ts @@ -0,0 +1,85 @@ +/* + * @adonisjs/vite + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { existsSync, readFileSync } from 'node:fs' +import type { Manifest } from 'vite' + +/** + * Resolves and imports server-side modules from the production SSR build. + * + * In production there is no Vite dev server — entrypoints declared as + * `serverEntrypoints` are pre-built into `/server` and + * recorded in `/server/.vite/manifest.json`. The + * resolver reads that manifest to map an entry source path to the + * emitted bundle file, then imports it through Node's native `import()`. + * + * Imports are cached per entry so repeated calls reuse the same module + * instance and side-effects only run once. + */ +export class BundledModuleResolver { + /** + * Absolute path to `/server`. + */ + #serverDir: string + + /** + * Cache of in-flight or resolved module imports, keyed by entry. + * Stores the promise so concurrent callers share the same import. + */ + #cache = new Map>() + + /** + * Lazily-loaded manifest contents. Loaded once on first import call. + */ + #manifest?: Manifest + + constructor(buildDirectory: string) { + this.#serverDir = join(buildDirectory, 'server') + } + + async import(entry: string): Promise { + let pending = this.#cache.get(entry) + if (!pending) { + const manifest = this.#readManifest() + const chunk = manifest[entry] + if (!chunk) { + throw new Error( + `Cannot loadServerModule("${entry}"): no chunk for this entry in ` + + `the SSR manifest. Make sure the entry is declared in ` + + `serverEntrypoints and the application has been rebuilt.` + ) + } + + const filePath = join(this.#serverDir, chunk.file) + pending = import(pathToFileURL(filePath).href) + this.#cache.set(entry, pending) + } + + return pending as Promise + } + + #readManifest(): Manifest { + if (this.#manifest) { + return this.#manifest + } + + const manifestPath = join(this.#serverDir, '.vite/manifest.json') + if (!existsSync(manifestPath)) { + throw new Error( + `SSR manifest not found at ${manifestPath}. Build the application ` + + `with at least one declared serverEntrypoint before loading server modules.` + ) + } + + this.#manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) + return this.#manifest! + } +} diff --git a/src/server_modules/dev_module_runner.ts b/src/server_modules/dev_module_runner.ts new file mode 100644 index 0000000..de8045b --- /dev/null +++ b/src/server_modules/dev_module_runner.ts @@ -0,0 +1,81 @@ +/* + * @adonisjs/vite + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { DevEnvironment, ViteDevServer } from 'vite' +import type { ModuleRunner } from 'vite/module-runner' + +import type { LoadServerModuleOptions } from '../types.ts' + +/** + * Hosts the Vite `ModuleRunner` used to evaluate server-side modules in + * development mode. + * + * Owns a single shared runner across all `loadServerModule` calls, and + * detects Vite dev server restarts (which replace `server.environments.ssr` + * with a fresh instance) so the stale runner is closed and recreated. + */ +export class DevModuleRunner { + /** + * Shared module runner. Lazy-created on first import. + */ + #runner?: ModuleRunner + + /** + * Reference to the SSR environment the current runner was created from. + * When Vite restarts the dev server, `server.environments.ssr` is + * replaced — we use this reference to detect that and recreate. + */ + #ssrEnvironment?: DevEnvironment + + /** + * Factory provided by the orchestrator to lazily create the runner. + * Indirection keeps this class decoupled from how the runner is built. + */ + #createRunner: () => Promise + + constructor(createRunner: () => Promise) { + this.#createRunner = createRunner + } + + /** + * Imports a module through the runner. Recreates the runner if the + * dev server's SSR environment was swapped underneath us. + */ + async import(server: ViteDevServer, entry: string, opts: LoadServerModuleOptions): Promise { + const currentSsrEnv = server.environments.ssr + + if (this.#ssrEnvironment !== currentSsrEnv) { + if (this.#runner) { + await this.#runner.close() + } + this.#runner = undefined + this.#ssrEnvironment = currentSsrEnv + } + + this.#runner ??= await this.#createRunner() + + if (opts.fresh) { + this.#runner.clearCache() + } + + return this.#runner.import(entry) + } + + /** + * Closes the runner, if any. Safe to call when no runner has been + * created yet. + */ + async close(): Promise { + if (this.#runner) { + await this.#runner.close() + this.#runner = undefined + this.#ssrEnvironment = undefined + } + } +} diff --git a/src/server_modules/server_module_loader.ts b/src/server_modules/server_module_loader.ts new file mode 100644 index 0000000..58b7b31 --- /dev/null +++ b/src/server_modules/server_module_loader.ts @@ -0,0 +1,61 @@ +/* + * @adonisjs/vite + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import type { ViteDevServer } from 'vite' +import type { ModuleRunner } from 'vite/module-runner' + +import { DevModuleRunner } from './dev_module_runner.ts' +import { BundledModuleResolver } from './bundled_module_resolver.ts' +import type { LoadServerModuleOptions } from '../types.ts' + +/** + * Public entry point for loading server-side modules processed by Vite. + * + * Picks between two collaborators based on whether the Vite dev server + * is running: + * + * - In development, delegates to {@link DevModuleRunner} which evaluates + * the entry through Vite's `ModuleRunner`, with HMR-driven cache + * invalidation. + * - In production, delegates to {@link BundledModuleResolver} which + * imports the pre-built bundle from disk. + * + * Entry strings are passed through verbatim to mirror how client + * `entrypoints` are handled — the caller is responsible for using the + * exact string declared in `serverEntrypoints`. + */ +export class ServerModuleLoader { + #getDevServer: () => ViteDevServer | undefined + #devRunner: DevModuleRunner + #bundled: BundledModuleResolver + + constructor( + getDevServer: () => ViteDevServer | undefined, + buildDirectory: string, + createRunner: () => Promise + ) { + this.#getDevServer = getDevServer + this.#devRunner = new DevModuleRunner(createRunner) + this.#bundled = new BundledModuleResolver(buildDirectory) + } + + async load(entry: string, opts: LoadServerModuleOptions = {}): Promise { + const server = this.#getDevServer() + + if (server) { + return this.#devRunner.import(server, entry, opts) + } + + return this.#bundled.import(entry) + } + + async close(): Promise { + await this.#devRunner.close() + } +} diff --git a/src/types.ts b/src/types.ts index 60b61a0..30dc740 100644 --- a/src/types.ts +++ b/src/types.ts @@ -74,3 +74,36 @@ export interface ViteOptions { */ scriptAttributes?: SetAttributes } + +/** + * Augmentable map for typing entries passed to `vite.loadServerModule`. + * Apps and packages merge into this interface to associate entrypoint + * paths with the shape of their default exports. + * + * @example + * declare module '@adonisjs/vite/types' { + * interface ServerModuleMap { + * 'inertia/app/ssr.ts': typeof import('../inertia/app/ssr.ts') + * } + * } + */ +export interface ServerModuleMap {} + +/** + * Options accepted by `vite.loadServerModule`. + */ +export interface LoadServerModuleOptions { + /** + * Clear the module runner cache before importing in dev mode. + * + * Defaults to `false` — Vite's HMR pushes invalidations into the + * runner, so cached modules are already kept fresh on file change. + * Set to `true` only when the entrypoint registers top-level state + * that must be reset on every load. + * + * Has no effect in production (bundled imports are always cached). + * + * @default false + */ + fresh?: boolean +} diff --git a/src/vite.ts b/src/vite.ts index 8335bf0..2959285 100644 --- a/src/vite.ts +++ b/src/vite.ts @@ -21,7 +21,14 @@ import type { } from 'vite' import { makeAttributes, uniqBy } from './utils.ts' -import type { AdonisViteElement, SetAttributes, ViteOptions } from './types.ts' +import { ServerModuleLoader } from './server_modules/server_module_loader.ts' +import type { + AdonisViteElement, + LoadServerModuleOptions, + ServerModuleMap, + SetAttributes, + ViteOptions, +} from './types.ts' const STYLE_FILE_REGEX = /\.(css|less|sass|scss|styl|stylus|pcss|postcss)($|\?)/ @@ -38,6 +45,12 @@ export class Vite { #options: ViteOptions #devServer?: ViteDevServer + /** + * Loads server-side TypeScript modules through Vite. Picks between + * the dev `ModuleRunner` and the production SSR bundle automatically. + */ + #serverModuleLoader: ServerModuleLoader + /** * Indicates whether the Vite manifest file exists on disk */ @@ -63,6 +76,11 @@ export class Vite { this.#options = options this.#options.assetsUrl = (this.#options.assetsUrl || '/').replace(/\/$/, '') this.hasManifestFile = existsSync(this.#options.manifestFile) + this.#serverModuleLoader = new ServerModuleLoader( + () => this.#devServer, + this.#options.buildDirectory, + () => this.createModuleRunner() + ) } /** @@ -535,6 +553,34 @@ export class Vite { return createServerModuleRunner(this.#devServer!.environments.ssr, options) } + /** + * Loads a server-side module that has been processed by Vite. + * + * In development, the entry is evaluated through Vite's `ModuleRunner`, + * giving full TypeScript / JSX / plugin support and HMR-driven cache + * invalidation. In production, the entry is imported from the + * pre-built SSR bundle on disk. + * + * The entry path must be declared in `serverEntrypoints` for the + * production import to succeed — otherwise the bundle won't exist. + * + * @example + * const mod = await vite.loadServerModule('inertia/app/ssr.ts') + * const html = await mod.default(payload) + * + * @example + * // Force re-evaluation (clear runner cache before import) + * await vite.loadServerModule('emails/welcome.ts', { fresh: true }) + */ + loadServerModule( + entry: K, + opts?: LoadServerModuleOptions + ): Promise + loadServerModule(entry: string, opts?: LoadServerModuleOptions): Promise + loadServerModule(entry: string, opts?: LoadServerModuleOptions): Promise { + return this.#serverModuleLoader.load(entry, opts) + } + /** * Gracefully stops the Vite development server * @@ -544,6 +590,7 @@ export class Vite { * await vite.stopDevServer() */ async stopDevServer() { + await this.#serverModuleLoader.close() await this.#devServer?.close() } diff --git a/tests/backend/server_modules.spec.ts b/tests/backend/server_modules.spec.ts new file mode 100644 index 0000000..a37dd42 --- /dev/null +++ b/tests/backend/server_modules.spec.ts @@ -0,0 +1,197 @@ +/* + * @adonisjs/vite + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { join } from 'node:path' +import { test } from '@japa/runner' + +import { Vite } from '../../index.ts' +import { createVite } from './helpers.ts' +import { defineConfig } from '../../src/define_config.ts' +import { configHook } from '../../src/client/config.ts' +import { BundledModuleResolver } from '../../src/server_modules/bundled_module_resolver.ts' + +test.group('Vite | loadServerModule (dev)', () => { + test('imports a TypeScript module via the dev module runner', async ({ assert, fs }) => { + await fs.create( + 'ssr.ts', + `export default { greet: (name: string) => 'hello ' + name } + export const value = 42` + ) + + const vite = await createVite(defineConfig({})) + const mod = await vite.loadServerModule<{ + default: { greet: (n: string) => string } + value: number + }>('/ssr.ts') + + assert.equal(mod.value, 42) + assert.equal(mod.default.greet('world'), 'hello world') + }) + + test('imports a JSX module after Vite transform', async ({ assert, fs }) => { + await fs.create('render.tsx', `export default () => ({ tag: 'div', kids: ['hi'] })`) + + const vite = await createVite(defineConfig({})) + const mod = await vite.loadServerModule<{ default: () => any }>('/render.tsx') + + assert.deepEqual(mod.default(), { tag: 'div', kids: ['hi'] }) + }) + + test('reuses the same runner across calls', async ({ assert, fs }) => { + await fs.create('a.ts', `export const v = 1`) + await fs.create('b.ts', `export const v = 2`) + + const vite = await createVite(defineConfig({})) + const a = await vite.loadServerModule<{ v: number }>('/a.ts') + const b = await vite.loadServerModule<{ v: number }>('/b.ts') + + assert.equal(a.v, 1) + assert.equal(b.v, 2) + }) + + test('fresh: true forces re-evaluation of cached modules', async ({ assert, fs }) => { + await fs.create('counter.ts', `export const ts = Date.now() + Math.random()`) + + const vite = await createVite(defineConfig({})) + const first = await vite.loadServerModule<{ ts: number }>('/counter.ts') + const cached = await vite.loadServerModule<{ ts: number }>('/counter.ts') + const fresh = await vite.loadServerModule<{ ts: number }>('/counter.ts', { fresh: true }) + + assert.equal(first.ts, cached.ts, 'expected default import to share cached instance') + assert.notEqual(first.ts, fresh.ts, 'expected fresh: true to re-evaluate the module') + }) +}) + +test.group('Vite | loadServerModule (prod)', () => { + test('imports the bundle pointed to by the SSR manifest', async ({ assert, fs }) => { + await fs.create('public/assets/server/ssr.js', `export default { msg: 'from-bundle' }`) + await fs.create( + 'public/assets/server/.vite/manifest.json', + JSON.stringify({ + 'inertia/ssr.ts': { file: 'ssr.js', isEntry: true, src: 'inertia/ssr.ts' }, + }) + ) + + const vite = new Vite(defineConfig({ buildDirectory: join(fs.basePath, 'public/assets') })) + const mod = await vite.loadServerModule<{ default: { msg: string } }>('inertia/ssr.ts') + + assert.equal(mod.default.msg, 'from-bundle') + }) + + test('caches the import promise per entry', async ({ assert, fs }) => { + await fs.create( + 'public/assets/server/once.js', + `globalThis.__loadCount = (globalThis.__loadCount ?? 0) + 1 + export const count = globalThis.__loadCount` + ) + await fs.create( + 'public/assets/server/.vite/manifest.json', + JSON.stringify({ + 'once.ts': { file: 'once.js', isEntry: true, src: 'once.ts' }, + }) + ) + + const vite = new Vite(defineConfig({ buildDirectory: join(fs.basePath, 'public/assets') })) + const a = await vite.loadServerModule<{ count: number }>('once.ts') + const b = await vite.loadServerModule<{ count: number }>('once.ts') + + assert.equal(a.count, b.count, 'expected the bundle to load only once') + }) + + test('throws when entry is not present in the manifest', async ({ assert, fs }) => { + await fs.create('public/assets/server/.vite/manifest.json', JSON.stringify({})) + + const resolver = new BundledModuleResolver(join(fs.basePath, 'public/assets')) + + await assert.rejects( + () => resolver.import('missing.ts'), + /no chunk for this entry in the SSR manifest/ + ) + }) + + test('throws when the SSR manifest is missing on disk', async ({ assert, fs }) => { + const resolver = new BundledModuleResolver(join(fs.basePath, 'public/assets')) + + await assert.rejects(() => resolver.import('any.ts'), /SSR manifest not found at /) + }) +}) + +test.group('Vite | configHook serverEntrypoints', () => { + test('does not configure ssr environment when serverEntrypoints is empty', ({ assert }) => { + const result = configHook( + { + assetsUrl: '/assets', + buildDirectory: 'public/assets', + reload: [], + entrypoints: ['app.ts'], + serverEntrypoints: [], + }, + { root: '/project' }, + { command: 'build' } as any + ) + + assert.notProperty(result, 'environments') + }) + + test('configures ssr environment when serverEntrypoints is non-empty', ({ assert }) => { + const result = configHook( + { + assetsUrl: '/assets', + buildDirectory: 'public/assets', + reload: [], + entrypoints: ['app.ts'], + serverEntrypoints: ['inertia/ssr.ts'], + }, + { root: '/project' }, + { command: 'build' } as any + ) + + assert.deepEqual(result.environments?.ssr?.build, { + ssr: true, + emptyOutDir: true, + emitAssets: false, + manifest: true, + outDir: join('public/assets', 'server'), + rolldownOptions: { + input: ['inertia/ssr.ts'], + }, + }) + }) + + test('respects user-supplied ssr build values', ({ assert }) => { + const result = configHook( + { + assetsUrl: '/assets', + buildDirectory: 'public/assets', + reload: [], + entrypoints: ['app.ts'], + serverEntrypoints: ['inertia/ssr.ts'], + }, + { + root: '/project', + environments: { + ssr: { + build: { + outDir: 'custom/server', + manifest: 'custom-manifest.json', + rolldownOptions: { input: { custom: '/project/other.ts' } }, + }, + }, + }, + }, + { command: 'build' } as any + ) + + assert.equal(result.environments?.ssr?.build?.outDir, 'custom/server') + assert.equal(result.environments?.ssr?.build?.manifest, 'custom-manifest.json') + assert.deepEqual(result.environments?.ssr?.build?.rolldownOptions?.input, { + custom: '/project/other.ts', + }) + }) +}) diff --git a/tests/client/config.spec.ts b/tests/client/config.spec.ts index d4c9e29..50b6fa7 100644 --- a/tests/client/config.spec.ts +++ b/tests/client/config.spec.ts @@ -67,8 +67,8 @@ test.group('Vite plugin', () => { // @ts-ignore const config = plugin!.config!({ root: '/app' }, { command: 'build' }) assert.deepEqual(config.build?.rolldownOptions?.input, [ - '/app/resources/js/app.ts', - '/app/resources/js/admin.ts', + './resources/js/app.ts', + './resources/js/admin.ts', ]) }) diff --git a/tests/client/resolve_assets.spec.ts b/tests/client/resolve_assets.spec.ts index 825c150..9c48013 100644 --- a/tests/client/resolve_assets.spec.ts +++ b/tests/client/resolve_assets.spec.ts @@ -41,11 +41,10 @@ test.group('resolveAssets | shape', () => { ) }) - test('returns two plugins when chunks are provided', ({ assert }) => { + test('returns plugin when chunks are provided', ({ assert }) => { const plugins = resolveAssets({ chunks: ['./resources/images/**/*.svg'] }) - assert.lengthOf(plugins, 2) + assert.lengthOf(plugins, 1) assert.equal(plugins[0].name, 'adonisjs:resolve-assets') - assert.equal(plugins[1].name, 'adonisjs:resolve-assets:manifest') }) })