Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
33 changes: 32 additions & 1 deletion src/client/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
1 change: 1 addition & 0 deletions src/client/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export default function adonisjs(options: PluginOptions): PluginOption[] {
assetsUrl: '/assets',
buildDirectory: 'public/assets',
reload: ['./resources/views/**/*.edge'],
serverEntrypoints: [] as string[],
},
options
)
Expand Down
15 changes: 11 additions & 4 deletions src/client/reload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

/**
Expand All @@ -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
}

Expand Down
88 changes: 18 additions & 70 deletions src/client/resolve_assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `_<hash>.<ext>` 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.
Expand All @@ -47,11 +49,6 @@ export function resolveAssets(input: PluginOptions['assets']): Plugin[] {
}
}

const assetRefToSource = new Map<string, string>()
const toAbsolute = (file: string) => (isAbsolute(file) ? file : resolve(root, file))

let manifestFileName = '.vite/manifest.json'
let manifestEnabled = true
let root = process.cwd()

return [
Expand All @@ -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()
},
},
]
Expand Down
10 changes: 10 additions & 0 deletions src/client/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<buildDirectory>/server/<name>.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.
Expand Down
19 changes: 18 additions & 1 deletion src/hooks/build_hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
85 changes: 85 additions & 0 deletions src/server_modules/bundled_module_resolver.ts
Original file line number Diff line number Diff line change
@@ -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 `<buildDirectory>/server` and
* recorded in `<buildDirectory>/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 `<buildDirectory>/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<string, Promise<unknown>>()

/**
* Lazily-loaded manifest contents. Loaded once on first import call.
*/
#manifest?: Manifest

constructor(buildDirectory: string) {
this.#serverDir = join(buildDirectory, 'server')
}

async import<T>(entry: string): Promise<T> {
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<T>
}

#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!
}
}
Loading
Loading