mirror of https://github.com/vuejs/vitepress
parent
fe6c1ff2e9
commit
60add88c16
@ -0,0 +1 @@
|
||||
copied once by the client build
|
||||
@ -0,0 +1,14 @@
|
||||
---
|
||||
title: Scoped batching page
|
||||
description: A page that must preserve its physical Markdown module identity.
|
||||
---
|
||||
|
||||
# Scoped batching page
|
||||
|
||||
<div class="scoped-batch-marker">Scoped module identity</div>
|
||||
|
||||
<style scoped>
|
||||
.scoped-batch-marker {
|
||||
color: rgb(1, 2, 3);
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,18 @@
|
||||
---
|
||||
title: Static batching page
|
||||
description: A page eligible for the direct static SSR path.
|
||||
---
|
||||
|
||||
# Static batching page
|
||||
|
||||
This content is rendered without evaluating a per-page SSR module.
|
||||
|
||||
<p data-static-batch-marker="preserved">Static HTML marker</p>
|
||||
|
||||
## Static presentational markup
|
||||
|
||||
<span class="VPBadge warning">static badge</span>
|
||||
|
||||
<!-- static comment preserved -->
|
||||
|
||||
<img data-static-public-asset src="/batch-public.txt" alt="Static public asset">
|
||||
@ -0,0 +1,437 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import {
|
||||
captureClientAssetUrls,
|
||||
collectSsrRuntimeBridges,
|
||||
createSsrRuntimeBridgePlugin,
|
||||
createSsrRuntimeInput
|
||||
} from 'node/build/bundle'
|
||||
import type { SiteConfig } from 'node/config'
|
||||
import {
|
||||
build as viteBuild,
|
||||
normalizePath,
|
||||
type Plugin,
|
||||
type ResolvedConfig,
|
||||
type Rolldown
|
||||
} from 'vite'
|
||||
|
||||
function assetCaptureTransform(assetMap: Record<string, string>) {
|
||||
const plugin = captureClientAssetUrls(
|
||||
{ site: { base: '/' } } as SiteConfig,
|
||||
assetMap
|
||||
)
|
||||
const transform = plugin.transform as {
|
||||
handler(code: string, id: string): void
|
||||
}
|
||||
return transform.handler
|
||||
}
|
||||
|
||||
test('captures inlined assets without treating raw or arbitrary root strings as URLs', () => {
|
||||
const assetMap: Record<string, string> = Object.create(null)
|
||||
const transform = assetCaptureTransform(assetMap)
|
||||
|
||||
transform('export default "data:image/png;base64,cGl4ZWw="', '/logo.png')
|
||||
transform('export default "data:not-an-asset"', '/message.txt?raw')
|
||||
transform('export default "/arbitrary-string"', '/message.txt?custom')
|
||||
|
||||
expect(assetMap['/logo.png']).toBe('data:image/png;base64,cGl4ZWw=')
|
||||
expect(assetMap['/message.txt?raw']).toBeUndefined()
|
||||
expect(assetMap['/message.txt?custom']).toBeUndefined()
|
||||
})
|
||||
|
||||
test('rejects runtime renderBuiltUrl expressions for batched SSR assets', () => {
|
||||
const assetMap: Record<string, string> = Object.create(null)
|
||||
const plugin = captureClientAssetUrls(
|
||||
{ site: { base: '/' } } as SiteConfig,
|
||||
assetMap
|
||||
)
|
||||
const configResolved = plugin.configResolved as (
|
||||
config: ResolvedConfig
|
||||
) => void
|
||||
configResolved({
|
||||
experimental: {
|
||||
renderBuiltUrl() {
|
||||
return { runtime: 'globalThis.__assetUrl' }
|
||||
}
|
||||
}
|
||||
} as ResolvedConfig)
|
||||
|
||||
const transform = plugin.transform as {
|
||||
handler(code: string, id: string): void
|
||||
}
|
||||
const assetId = '/logo.svg?url'
|
||||
transform.handler('export default "__VITE_ASSET__logo__"', assetId)
|
||||
|
||||
const generateBundle = plugin.generateBundle as (
|
||||
this: Rolldown.PluginContext,
|
||||
options: Rolldown.NormalizedOutputOptions,
|
||||
bundle: Rolldown.OutputBundle
|
||||
) => void
|
||||
expect(() =>
|
||||
generateBundle.call(
|
||||
{
|
||||
getFileName() {
|
||||
return 'assets/logo.svg'
|
||||
}
|
||||
} as unknown as Rolldown.PluginContext,
|
||||
{} as Rolldown.NormalizedOutputOptions,
|
||||
{
|
||||
'page.js': {
|
||||
type: 'chunk',
|
||||
moduleIds: [assetId],
|
||||
fileName: 'page.js'
|
||||
}
|
||||
} as Rolldown.OutputBundle
|
||||
)
|
||||
).toThrow(
|
||||
'ssrBuildBatchSize cannot materialize the runtime renderBuiltUrl expression for assets/logo.svg. Return a URL string for SSR assets instead.'
|
||||
)
|
||||
})
|
||||
|
||||
function invokeModuleParsed(
|
||||
plugin: Plugin,
|
||||
moduleInfo: Pick<Rolldown.ModuleInfo, 'id' | 'isEntry'> &
|
||||
Partial<Rolldown.ModuleInfo>,
|
||||
emitFile: (file: Rolldown.EmittedFile) => string
|
||||
) {
|
||||
const handler = plugin.moduleParsed as (
|
||||
this: Rolldown.PluginContext,
|
||||
moduleInfo: Rolldown.ModuleInfo
|
||||
) => void
|
||||
handler.call(
|
||||
{ emitFile } as unknown as Rolldown.PluginContext,
|
||||
{
|
||||
importers: [],
|
||||
dynamicImporters: [],
|
||||
importedIds: [],
|
||||
dynamicallyImportedIds: [],
|
||||
...moduleInfo
|
||||
} as Rolldown.ModuleInfo
|
||||
)
|
||||
}
|
||||
|
||||
async function invokeBuildStart(plugin: Plugin, resolvedId: string) {
|
||||
const handler = plugin.buildStart as (
|
||||
this: Rolldown.PluginContext
|
||||
) => Promise<void>
|
||||
const resolve = vi.fn(
|
||||
async () => ({ id: resolvedId, external: false }) as Rolldown.ResolvedId
|
||||
)
|
||||
await handler.call({
|
||||
resolve,
|
||||
error(message: string | Rolldown.RollupError): never {
|
||||
throw new Error(typeof message === 'string' ? message : message.message)
|
||||
}
|
||||
} as unknown as Rolldown.PluginContext)
|
||||
return resolve
|
||||
}
|
||||
|
||||
test('declares only runtime roots instead of every file in a custom theme', () => {
|
||||
const bridgeModuleIds = new Set<string>()
|
||||
const input = createSsrRuntimeInput(
|
||||
{
|
||||
themeDir: path.join(process.cwd(), 'site/.vitepress/theme')
|
||||
} as SiteConfig,
|
||||
bridgeModuleIds
|
||||
)
|
||||
|
||||
expect(input).toMatchObject({
|
||||
app: expect.any(String),
|
||||
vitepress: expect.any(String),
|
||||
theme: expect.any(String),
|
||||
'site-theme': '@theme/index'
|
||||
})
|
||||
expect(Object.keys(input)).toEqual([
|
||||
'app',
|
||||
'vitepress',
|
||||
'theme',
|
||||
'site-theme'
|
||||
])
|
||||
expect([...bridgeModuleIds].sort()).toEqual(
|
||||
[normalizePath(input.vitepress), normalizePath(input.theme)].sort()
|
||||
)
|
||||
})
|
||||
|
||||
test('emits bounded facades for all site-local and virtual theme dependencies', async () => {
|
||||
const themeDir = path.join(process.cwd(), 'site/.vitepress/theme')
|
||||
const indexId = normalizePath(path.join(themeDir, 'index.ts'))
|
||||
const componentId = normalizePath(
|
||||
path.join(themeDir, 'components/Widget.vue')
|
||||
)
|
||||
const componentScriptId = `${componentId}?vue&type=script&lang.ts`
|
||||
const componentStyleId = `${componentId}?vue&type=style&index=0&lang.css`
|
||||
const sharedId = normalizePath(path.join(themeDir, '../../shared/state.ts'))
|
||||
const virtualId = '\0test:theme-singleton'
|
||||
const virtualAssetId = '\0test:theme-logo.svg'
|
||||
const customAssetId = '\0test:custom-asset'
|
||||
const dependencyId = normalizePath(
|
||||
path.join(process.cwd(), 'node_modules/example/index.js')
|
||||
)
|
||||
const nativeId = 'node:crypto'
|
||||
const bridgeModuleIds = new Set<string>()
|
||||
const plugin = createSsrRuntimeBridgePlugin({ themeDir }, bridgeModuleIds)
|
||||
const emitFile = vi.fn((_file: Rolldown.EmittedFile) => 'bridge')
|
||||
|
||||
const resolve = await invokeBuildStart(plugin, indexId)
|
||||
// A source may be parsed first through another runtime entry. The final
|
||||
// bridge set must not depend on Rolldown's traversal order.
|
||||
invokeModuleParsed(plugin, { id: sharedId, isEntry: false }, emitFile)
|
||||
invokeModuleParsed(plugin, { id: virtualId, isEntry: false }, emitFile)
|
||||
invokeModuleParsed(
|
||||
plugin,
|
||||
{
|
||||
id: indexId,
|
||||
isEntry: true,
|
||||
importedIds: [
|
||||
componentId,
|
||||
virtualId,
|
||||
virtualAssetId,
|
||||
customAssetId,
|
||||
dependencyId,
|
||||
nativeId
|
||||
]
|
||||
},
|
||||
emitFile
|
||||
)
|
||||
invokeModuleParsed(
|
||||
plugin,
|
||||
{
|
||||
id: componentId,
|
||||
isEntry: false,
|
||||
importedIds: [componentScriptId, componentStyleId]
|
||||
},
|
||||
emitFile
|
||||
)
|
||||
invokeModuleParsed(plugin, { id: componentId, isEntry: false }, emitFile)
|
||||
invokeModuleParsed(
|
||||
plugin,
|
||||
{
|
||||
id: componentScriptId,
|
||||
isEntry: false,
|
||||
importedIds: [sharedId]
|
||||
},
|
||||
emitFile
|
||||
)
|
||||
invokeModuleParsed(plugin, { id: componentStyleId, isEntry: false }, emitFile)
|
||||
invokeModuleParsed(plugin, { id: virtualAssetId, isEntry: false }, emitFile)
|
||||
invokeModuleParsed(
|
||||
plugin,
|
||||
{
|
||||
id: customAssetId,
|
||||
isEntry: false,
|
||||
meta: { 'vite:asset': true }
|
||||
},
|
||||
emitFile
|
||||
)
|
||||
invokeModuleParsed(plugin, { id: dependencyId, isEntry: false }, emitFile)
|
||||
invokeModuleParsed(plugin, { id: nativeId, isEntry: false }, emitFile)
|
||||
invokeModuleParsed(
|
||||
plugin,
|
||||
{
|
||||
id: normalizePath(path.join(themeDir, 'ambient.d.ts')),
|
||||
isEntry: false
|
||||
},
|
||||
emitFile
|
||||
)
|
||||
invokeModuleParsed(
|
||||
plugin,
|
||||
{ id: `${componentId}?vue&type=style`, isEntry: false },
|
||||
emitFile
|
||||
)
|
||||
invokeModuleParsed(
|
||||
plugin,
|
||||
{
|
||||
id: normalizePath(path.join(themeDir, '../theme-story/Story.ts')),
|
||||
isEntry: false
|
||||
},
|
||||
emitFile
|
||||
)
|
||||
|
||||
expect(resolve).toHaveBeenCalledWith('@theme/index', undefined, {
|
||||
isEntry: true
|
||||
})
|
||||
expect([...bridgeModuleIds].sort()).toEqual(
|
||||
[indexId, componentId, sharedId, virtualId].sort()
|
||||
)
|
||||
expect(emitFile).toHaveBeenCalledTimes(3)
|
||||
for (const id of [componentId, sharedId, virtualId]) {
|
||||
expect(emitFile).toHaveBeenCalledWith({
|
||||
type: 'chunk',
|
||||
id,
|
||||
name: expect.stringMatching(/^site-runtime-[a-f\d]{16}$/),
|
||||
preserveSignature: 'strict'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test('runtime facades preserve local and virtual singleton identity', async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'vitepress-runtime-bridge-'))
|
||||
const themeDir = path.join(root, '.vitepress/theme')
|
||||
const dependencyDir = path.join(root, 'node_modules/runtime-dependency')
|
||||
const outDir = path.join(root, 'out')
|
||||
const appId = path.join(root, 'app.js')
|
||||
const themeId = path.join(themeDir, 'index.js')
|
||||
const sharedId = path.join(root, 'shared.js')
|
||||
const virtualId = '\0test:virtual-singleton'
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
mkdir(themeDir, { recursive: true }),
|
||||
mkdir(dependencyDir, { recursive: true })
|
||||
])
|
||||
await Promise.all([
|
||||
writeFile(appId, `export * from '@theme/index'`),
|
||||
writeFile(
|
||||
themeId,
|
||||
[
|
||||
`export { localSingleton } from '../../shared.js'`,
|
||||
`export { virtualSingleton } from 'virtual:singleton'`,
|
||||
`export { dependencySingleton } from 'runtime-dependency'`,
|
||||
`export { types as nativeTypes } from 'node:util'`
|
||||
].join('\n')
|
||||
),
|
||||
writeFile(sharedId, `export const localSingleton = { local: true }`),
|
||||
writeFile(
|
||||
path.join(dependencyDir, 'package.json'),
|
||||
JSON.stringify({ type: 'module', exports: './index.js' })
|
||||
),
|
||||
writeFile(
|
||||
path.join(dependencyDir, 'index.js'),
|
||||
`export const dependencySingleton = { dependency: true }`
|
||||
)
|
||||
])
|
||||
|
||||
const bridgeModuleIds = new Set<string>()
|
||||
const result = (await viteBuild({
|
||||
root,
|
||||
configFile: false,
|
||||
logLevel: 'silent',
|
||||
resolve: {
|
||||
alias: { '@theme/index': themeId }
|
||||
},
|
||||
plugins: [
|
||||
{
|
||||
name: 'test:virtual-singleton',
|
||||
resolveId(id) {
|
||||
if (id === 'virtual:singleton') return virtualId
|
||||
},
|
||||
load(id) {
|
||||
if (id === virtualId) {
|
||||
return `export const virtualSingleton = { virtual: true }`
|
||||
}
|
||||
}
|
||||
},
|
||||
createSsrRuntimeBridgePlugin({ themeDir }, bridgeModuleIds)
|
||||
],
|
||||
build: {
|
||||
ssr: true,
|
||||
outDir,
|
||||
minify: false,
|
||||
rolldownOptions: {
|
||||
input: { app: appId, 'site-theme': '@theme/index' },
|
||||
preserveEntrySignatures: 'strict',
|
||||
output: {
|
||||
entryFileNames: '[name].mjs',
|
||||
chunkFileNames: 'chunks/[name]-[hash].mjs'
|
||||
}
|
||||
}
|
||||
}
|
||||
})) as Rolldown.RolldownOutput
|
||||
|
||||
const normalizedThemeId = [...bridgeModuleIds].find((id) =>
|
||||
id.endsWith('/.vitepress/theme/index.js')
|
||||
)
|
||||
const normalizedSharedId = [...bridgeModuleIds].find((id) =>
|
||||
id.endsWith('/shared.js')
|
||||
)
|
||||
expect(normalizedThemeId).toBeDefined()
|
||||
expect(normalizedSharedId).toBeDefined()
|
||||
expect([...bridgeModuleIds].sort()).toEqual(
|
||||
[normalizedThemeId!, normalizedSharedId!, virtualId].sort()
|
||||
)
|
||||
|
||||
const bridges = collectSsrRuntimeBridges(result, outDir, bridgeModuleIds)
|
||||
const appChunk = result.output.find(
|
||||
(output): output is Rolldown.OutputChunk =>
|
||||
output.type === 'chunk' && output.isEntry && output.name === 'app'
|
||||
)
|
||||
expect(appChunk).toBeDefined()
|
||||
|
||||
const externalImports = result.output.flatMap((output) =>
|
||||
output.type === 'chunk' ? output.imports : []
|
||||
)
|
||||
expect(externalImports).toContain('runtime-dependency')
|
||||
expect(externalImports).toContain('node:util')
|
||||
|
||||
const runtime = await import(
|
||||
pathToFileURL(path.resolve(outDir, appChunk!.fileName)).href
|
||||
)
|
||||
const localBridge = await import(
|
||||
pathToFileURL(bridges[normalizedSharedId!]).href
|
||||
)
|
||||
const virtualBridge = await import(pathToFileURL(bridges[virtualId]).href)
|
||||
|
||||
expect(runtime.localSingleton).toBe(localBridge.localSingleton)
|
||||
expect(runtime.virtualSingleton).toBe(virtualBridge.virtualSingleton)
|
||||
expect(runtime.dependencySingleton.dependency).toBe(true)
|
||||
expect(runtime.nativeTypes.isNativeError).toBeTypeOf('function')
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('collects only recorded runtime facades and rejects missing ones', () => {
|
||||
const outDir = path.join(process.cwd(), '.temp/runtime')
|
||||
const vitepressId = normalizePath(path.join(process.cwd(), 'client/index.js'))
|
||||
const themeId = normalizePath(path.join(process.cwd(), 'theme/index.ts'))
|
||||
const unrelatedId = normalizePath(
|
||||
path.join(process.cwd(), 'theme/Widget.story.ts')
|
||||
)
|
||||
const result = {
|
||||
output: [
|
||||
{
|
||||
type: 'chunk',
|
||||
isEntry: true,
|
||||
name: 'vitepress',
|
||||
facadeModuleId: vitepressId,
|
||||
fileName: 'vitepress.js'
|
||||
},
|
||||
{
|
||||
type: 'chunk',
|
||||
isEntry: true,
|
||||
name: 'site-theme',
|
||||
facadeModuleId: themeId,
|
||||
fileName: 'site-theme.js'
|
||||
},
|
||||
{
|
||||
type: 'chunk',
|
||||
isEntry: true,
|
||||
name: 'unrelated',
|
||||
facadeModuleId: unrelatedId,
|
||||
fileName: 'unrelated.js'
|
||||
}
|
||||
]
|
||||
} as unknown as Rolldown.RolldownOutput
|
||||
|
||||
const bridges = collectSsrRuntimeBridges(
|
||||
result,
|
||||
outDir,
|
||||
new Set([vitepressId, themeId])
|
||||
)
|
||||
expect(bridges).toEqual({
|
||||
[vitepressId]: path.resolve(outDir, 'vitepress.js'),
|
||||
[themeId]: path.resolve(outDir, 'site-theme.js')
|
||||
})
|
||||
expect(bridges[unrelatedId]).toBeUndefined()
|
||||
|
||||
const missingId = normalizePath(path.join(process.cwd(), 'theme/missing.ts'))
|
||||
expect(() =>
|
||||
collectSsrRuntimeBridges(
|
||||
result,
|
||||
outDir,
|
||||
new Set([vitepressId, themeId, missingId])
|
||||
)
|
||||
).toThrow(missingId)
|
||||
})
|
||||
@ -0,0 +1,94 @@
|
||||
import { resolvePageArtifactCachePolicy } from 'node/build/build'
|
||||
import type { SiteConfig } from 'node/config'
|
||||
|
||||
function createSiteConfig(
|
||||
markdown: SiteConfig['markdown'],
|
||||
overrides: Partial<SiteConfig> = {}
|
||||
): SiteConfig {
|
||||
return {
|
||||
cacheDir: '/persistent-cache',
|
||||
publicDir: '/site/public',
|
||||
cleanUrls: false,
|
||||
lastUpdated: false,
|
||||
ignoreDeadLinks: false,
|
||||
markdown,
|
||||
site: {
|
||||
base: '/',
|
||||
locales: {},
|
||||
themeConfig: {}
|
||||
},
|
||||
...overrides
|
||||
} as SiteConfig
|
||||
}
|
||||
|
||||
describe('page artifact cache policy', () => {
|
||||
test('persists declarative artifact configuration', () => {
|
||||
const config = createSiteConfig({
|
||||
lineNumbers: true,
|
||||
image: { lazyLoad: true },
|
||||
languageAlias: { shell: 'bash' }
|
||||
})
|
||||
|
||||
expect(resolvePageArtifactCachePolicy(config, '/build-a')).toEqual({
|
||||
persistent: true,
|
||||
root: '/persistent-cache'
|
||||
})
|
||||
})
|
||||
|
||||
test('uses an isolated per-build store when cache is disabled', () => {
|
||||
const config = createSiteConfig({
|
||||
cache: false,
|
||||
cacheKey: 'ignored-while-disabled'
|
||||
})
|
||||
|
||||
const first = resolvePageArtifactCachePolicy(config, '/build-a')
|
||||
const second = resolvePageArtifactCachePolicy(config, '/build-b')
|
||||
|
||||
expect(first).toEqual({
|
||||
persistent: false,
|
||||
root: '/build-a/page-artifact-cache'
|
||||
})
|
||||
expect(second).toEqual({
|
||||
persistent: false,
|
||||
root: '/build-b/page-artifact-cache'
|
||||
})
|
||||
expect(first.root).not.toBe(second.root)
|
||||
})
|
||||
|
||||
test('does not persist opaque hook closures without an explicit key', () => {
|
||||
const markdownHook = createSiteConfig({ config() {} })
|
||||
const viteHook = createSiteConfig(undefined, {
|
||||
vite: { plugins: [{ name: 'opaque', transform() {} }] }
|
||||
})
|
||||
|
||||
expect(
|
||||
resolvePageArtifactCachePolicy(markdownHook, '/build').persistent
|
||||
).toBe(false)
|
||||
expect(resolvePageArtifactCachePolicy(viteHook, '/build').persistent).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
test('an explicit whole-page key opts opaque hooks into persistence', () => {
|
||||
const config = createSiteConfig(
|
||||
{ cacheKey: 'external-state-v2', config() {} },
|
||||
{
|
||||
transformPageData() {},
|
||||
vite: { plugins: [{ name: 'opaque', transform() {} }] }
|
||||
}
|
||||
)
|
||||
|
||||
expect(resolvePageArtifactCachePolicy(config, '/build')).toEqual({
|
||||
persistent: true,
|
||||
root: '/persistent-cache'
|
||||
})
|
||||
})
|
||||
|
||||
test('rejects an empty explicit key', () => {
|
||||
const config = createSiteConfig({ cacheKey: ' ' })
|
||||
|
||||
expect(() => resolvePageArtifactCachePolicy(config, '/build')).toThrow(
|
||||
'markdown.cacheKey must be a non-empty string.'
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,139 @@
|
||||
import type { SiteConfig } from 'node/config'
|
||||
import {
|
||||
createRenderMetadata,
|
||||
deserializeRenderMetadata,
|
||||
deserializeRenderedPage,
|
||||
serializeRenderMetadata,
|
||||
serializeRenderedPage
|
||||
} from 'node/build/render'
|
||||
import type { Rolldown } from 'vite'
|
||||
|
||||
const chunk = (values: Partial<Rolldown.OutputChunk>): Rolldown.OutputChunk =>
|
||||
({
|
||||
type: 'chunk',
|
||||
fileName: '',
|
||||
name: '',
|
||||
code: '',
|
||||
imports: [],
|
||||
moduleIds: [],
|
||||
isEntry: false,
|
||||
...values
|
||||
}) as Rolldown.OutputChunk
|
||||
|
||||
const asset = (fileName: string): Rolldown.OutputAsset =>
|
||||
({
|
||||
type: 'asset',
|
||||
fileName,
|
||||
names: [],
|
||||
originalFileNames: [],
|
||||
source: ''
|
||||
}) as Rolldown.OutputAsset
|
||||
|
||||
test('retains only compact client metadata and round-trips maps', () => {
|
||||
const pagePath = '/site/guide.md'
|
||||
const clientResult = {
|
||||
output: [
|
||||
chunk({
|
||||
fileName: 'assets/app.123.js',
|
||||
facadeModuleId: '/vitepress/app/index.js',
|
||||
imports: ['assets/framework.js'],
|
||||
isEntry: true,
|
||||
code: 'large app code that must not be retained'
|
||||
}),
|
||||
chunk({
|
||||
fileName: 'assets/guide.123.js',
|
||||
facadeModuleId: pagePath,
|
||||
imports: ['assets/theme.js'],
|
||||
isEntry: true,
|
||||
code: 'large page code that must not be retained'
|
||||
}),
|
||||
chunk({
|
||||
name: 'theme',
|
||||
moduleIds: ['/vitepress/client/theme-default/index.js']
|
||||
}),
|
||||
asset('assets/style.123.css'),
|
||||
asset('assets/logo.123.svg')
|
||||
]
|
||||
} as Rolldown.RolldownOutput
|
||||
const config = {
|
||||
mpa: false,
|
||||
site: { base: '/docs/' }
|
||||
} as SiteConfig
|
||||
|
||||
const metadata = createRenderMetadata(config, clientResult, null)
|
||||
const serialized = serializeRenderMetadata(metadata)
|
||||
const restored = deserializeRenderMetadata(serialized)
|
||||
|
||||
expect(restored.appChunk).toEqual({
|
||||
fileName: 'assets/app.123.js',
|
||||
imports: ['assets/framework.js']
|
||||
})
|
||||
expect(restored.cssChunk).toEqual({ fileName: 'assets/style.123.css' })
|
||||
expect(restored.assets).toEqual(['/docs/assets/logo.123.svg'])
|
||||
expect(restored.isDefaultTheme).toBe(true)
|
||||
expect(restored.pageImports.get(pagePath)).toEqual(['assets/theme.js'])
|
||||
expect(JSON.stringify(serialized)).not.toContain('large page code')
|
||||
expect(JSON.stringify(serialized)).not.toContain('large app code')
|
||||
})
|
||||
|
||||
test('retains inlineable page chunks for normal MPA rendering', () => {
|
||||
const pagePath = '/site/index.md'
|
||||
const clientResult = {
|
||||
output: [
|
||||
chunk({
|
||||
fileName: 'assets/index.js',
|
||||
facadeModuleId: pagePath,
|
||||
isEntry: true,
|
||||
code: 'console.log("client")'
|
||||
})
|
||||
]
|
||||
} as Rolldown.RolldownOutput
|
||||
const serverResult = {
|
||||
output: [asset('assets/mpa.css')]
|
||||
} as Rolldown.RolldownOutput
|
||||
const config = { mpa: true, site: { base: '/' } } as SiteConfig
|
||||
|
||||
const metadata = createRenderMetadata(config, clientResult, serverResult)
|
||||
|
||||
expect(metadata.pageChunks.get(pagePath)).toEqual({
|
||||
fileName: 'assets/index.js',
|
||||
code: 'console.log("client")'
|
||||
})
|
||||
expect(metadata.cssChunk).toEqual({ fileName: 'assets/mpa.css' })
|
||||
})
|
||||
|
||||
test('round-trips worker render results with sorted Set-backed state', () => {
|
||||
const renderedPage = {
|
||||
page: 'guide.md',
|
||||
pageData: {
|
||||
title: 'Guide',
|
||||
description: '',
|
||||
frontmatter: {},
|
||||
headers: [],
|
||||
relativePath: 'guide.md',
|
||||
filePath: 'guide.md'
|
||||
},
|
||||
hasCustom404: true,
|
||||
context: {
|
||||
content: '<main>Guide</main>',
|
||||
teleports: { body: '<div>teleported</div>' },
|
||||
vpSocialIcons: new Set(['z-icon', 'a-icon'])
|
||||
}
|
||||
}
|
||||
|
||||
const serialized = serializeRenderedPage(renderedPage)
|
||||
expect(serialized.context.vpSocialIcons).toEqual(['a-icon', 'z-icon'])
|
||||
|
||||
const restored = deserializeRenderedPage(serialized)
|
||||
expect(restored).toMatchObject({
|
||||
page: renderedPage.page,
|
||||
pageData: renderedPage.pageData,
|
||||
hasCustom404: true,
|
||||
context: {
|
||||
content: '<main>Guide</main>',
|
||||
teleports: { body: '<div>teleported</div>' }
|
||||
}
|
||||
})
|
||||
expect(restored.context.vpSocialIcons).toBeInstanceOf(Set)
|
||||
expect([...restored.context.vpSocialIcons]).toEqual(['a-icon', 'z-icon'])
|
||||
})
|
||||
@ -0,0 +1,237 @@
|
||||
import {
|
||||
adaptSsrBatchPagePlugins,
|
||||
createSsrBatchPlan,
|
||||
createWorkerExecArgv,
|
||||
validateBuildConcurrency,
|
||||
validateSsrBatchPageOutputHooks,
|
||||
validateSsrBuildBatchSize,
|
||||
validateSsrBuildWorkerConcurrency
|
||||
} from 'node/build/ssrBatchUtils'
|
||||
import type { Plugin, Rolldown } from 'vite'
|
||||
|
||||
describe('SSR batch planning', () => {
|
||||
test('adapts frozen user plugins without changing internal plugins', () => {
|
||||
const userPlugin = Object.freeze({
|
||||
name: 'frozen-user-plugin',
|
||||
transform(this: any) {
|
||||
return `${this.environment.mode}:${this.meta.watchMode}:${typeof this.setAssetSource}`
|
||||
}
|
||||
}) as Plugin
|
||||
const internalPlugin = Object.freeze({
|
||||
name: 'vite:internal-test',
|
||||
transform() {}
|
||||
}) as Plugin
|
||||
|
||||
const [adaptedUser, adaptedInternal] = adaptSsrBatchPagePlugins([
|
||||
userPlugin,
|
||||
internalPlugin
|
||||
])
|
||||
expect(adaptedUser).not.toBe(userPlugin)
|
||||
expect(adaptedInternal).toBe(internalPlugin)
|
||||
|
||||
const transform = adaptedUser.transform
|
||||
const handler =
|
||||
typeof transform === 'function' ? transform : transform?.handler
|
||||
expect(
|
||||
handler?.call(
|
||||
{
|
||||
environment: { mode: 'dev' },
|
||||
meta: { watchMode: true }
|
||||
},
|
||||
'',
|
||||
'/page.js',
|
||||
{ moduleType: 'js', ssr: true }
|
||||
)
|
||||
).toBe('build:false:undefined')
|
||||
})
|
||||
|
||||
test('requires positive global build concurrency', () => {
|
||||
expect(validateBuildConcurrency(1)).toBe(1)
|
||||
expect(validateBuildConcurrency(64)).toBe(64)
|
||||
|
||||
for (const value of [undefined, 0, -1, 1.5, Number.NaN, Infinity, '2']) {
|
||||
expect(() => validateBuildConcurrency(value)).toThrow(
|
||||
'buildConcurrency must be a positive integer.'
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('requires a positive integer when configured', () => {
|
||||
expect(validateSsrBuildBatchSize(undefined)).toBeUndefined()
|
||||
expect(validateSsrBuildBatchSize(64)).toBe(64)
|
||||
|
||||
for (const value of [0, -1, 1.5, Number.NaN, Infinity, '2', null]) {
|
||||
expect(() => validateSsrBuildBatchSize(value)).toThrow(
|
||||
'ssrBuildBatchSize must be a positive integer.'
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('requires positive render-worker concurrency', () => {
|
||||
expect(validateSsrBuildWorkerConcurrency(1)).toBe(1)
|
||||
expect(validateSsrBuildWorkerConcurrency(4)).toBe(4)
|
||||
|
||||
for (const value of [undefined, 0, -1, 1.5, Number.NaN, '2', null]) {
|
||||
expect(() => validateSsrBuildWorkerConcurrency(value)).toThrow(
|
||||
'ssrBuildWorkerConcurrency must be a positive integer.'
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('partitions the render queue without reordering pages', () => {
|
||||
const pages = ['a.md', 'b.md', 'c.md', 'd.md', 'e.md']
|
||||
const batches = createSsrBatchPlan(pages, 2)
|
||||
|
||||
expect(batches.flatMap((batch) => batch.pages)).toEqual([
|
||||
'404.md',
|
||||
...pages
|
||||
])
|
||||
expect(batches.map((batch) => batch.offset)).toEqual([0, 2, 4])
|
||||
expect(batches.every((batch) => batch.pages.length <= 2)).toBe(true)
|
||||
})
|
||||
|
||||
test('supports a synthetic 404-only batch', () => {
|
||||
expect(createSsrBatchPlan([], 10)).toEqual([
|
||||
{ offset: 0, pages: ['404.md'] }
|
||||
])
|
||||
})
|
||||
|
||||
test('does not schedule a custom 404 page twice', () => {
|
||||
const batches = createSsrBatchPlan(['guide.md', '404.md'], 2)
|
||||
expect(batches.flatMap((batch) => batch.pages)).toEqual([
|
||||
'404.md',
|
||||
'guide.md'
|
||||
])
|
||||
})
|
||||
|
||||
test('accepts transform and teardown hooks plus Vite internal bundle hooks', async () => {
|
||||
const plugins = [
|
||||
{
|
||||
name: 'fabric-docs:transform-files',
|
||||
transform(code: string) {
|
||||
return code
|
||||
},
|
||||
buildEnd() {},
|
||||
closeBundle() {}
|
||||
},
|
||||
{
|
||||
name: 'vite:css-post',
|
||||
renderChunk() {},
|
||||
augmentChunkHash() {}
|
||||
},
|
||||
{
|
||||
name: 'vitepress',
|
||||
renderStart() {},
|
||||
generateBundle() {}
|
||||
}
|
||||
] as Plugin[]
|
||||
|
||||
await expect(
|
||||
validateSsrBatchPageOutputHooks(plugins, undefined)
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test('rejects user bundle-graph and output hooks with plugin and hook names', async () => {
|
||||
const plugins = [
|
||||
{
|
||||
name: 'custom-page-renderer',
|
||||
moduleParsed() {},
|
||||
renderChunk: { handler() {} },
|
||||
augmentChunkHash() {
|
||||
return 'custom'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'page-manifest',
|
||||
resolveDynamicImport() {
|
||||
return null
|
||||
},
|
||||
generateBundle() {}
|
||||
}
|
||||
] as Plugin[]
|
||||
|
||||
await expect(
|
||||
validateSsrBatchPageOutputHooks(plugins, undefined)
|
||||
).rejects.toThrow(
|
||||
[
|
||||
'SSR batching cannot preserve Rolldown bundle hooks for unbundled SSR page modules:',
|
||||
' - plugin "custom-page-renderer": moduleParsed, augmentChunkHash, renderChunk',
|
||||
' - plugin "page-manifest": resolveDynamicImport, generateBundle',
|
||||
'Disable ssrBuildBatchSize'
|
||||
].join('\n')
|
||||
)
|
||||
})
|
||||
|
||||
test('rejects async nested Rolldown output plugins and output addons', async () => {
|
||||
const outputPlugin = Promise.resolve({
|
||||
name: 'server-page-assets',
|
||||
writeBundle() {}
|
||||
})
|
||||
const output = {
|
||||
banner: '/* server page */',
|
||||
plugins: [false, [outputPlugin]]
|
||||
} as Rolldown.OutputOptions
|
||||
|
||||
await expect(validateSsrBatchPageOutputHooks([], output)).rejects.toThrow(
|
||||
[
|
||||
' - output options "output": banner',
|
||||
' - output plugin "server-page-assets": writeBundle'
|
||||
].join('\n')
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const inspectorOverrides = [
|
||||
'--no-inspect',
|
||||
'--no-inspect-brk',
|
||||
'--no-inspect-wait'
|
||||
].filter((flag) => process.allowedNodeEnvironmentFlags.has(flag))
|
||||
|
||||
test('worker exec arguments omit inspector flags and override NODE_OPTIONS', () => {
|
||||
expect(
|
||||
createWorkerExecArgv([
|
||||
'--enable-source-maps',
|
||||
'--inspect',
|
||||
'--inspect-brk=127.0.0.1:9230',
|
||||
'--inspect-port',
|
||||
'9231',
|
||||
'--loader',
|
||||
'tsx',
|
||||
'--inspect-publish-uid',
|
||||
'stderr,http',
|
||||
'--conditions=development'
|
||||
])
|
||||
).toEqual([
|
||||
'--enable-source-maps',
|
||||
'--loader',
|
||||
'tsx',
|
||||
'--conditions=development',
|
||||
...inspectorOverrides
|
||||
])
|
||||
})
|
||||
|
||||
test('worker exec arguments omit parent entrypoint modes', () => {
|
||||
expect(
|
||||
createWorkerExecArgv([
|
||||
'--enable-source-maps',
|
||||
'--',
|
||||
'-e',
|
||||
'build()',
|
||||
'--input-type',
|
||||
'module',
|
||||
'--test',
|
||||
'--watch-path',
|
||||
'src',
|
||||
'--test-coverage-include',
|
||||
'src/**/*.ts',
|
||||
'--watch-kill-signal=SIGTERM',
|
||||
'-pe',
|
||||
'process.version',
|
||||
'--conditions=development'
|
||||
])
|
||||
).toEqual([
|
||||
'--enable-source-maps',
|
||||
'--conditions=development',
|
||||
...inspectorOverrides
|
||||
])
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,22 @@
|
||||
import { serializeSsrRenderWorkerResult } from 'node/build/ssrWorkerProtocol'
|
||||
|
||||
describe('SSR render-worker result protocol', () => {
|
||||
test('explains the batching constraint for custom non-transferable context', () => {
|
||||
expect(() =>
|
||||
serializeSsrRenderWorkerResult({
|
||||
pages: [
|
||||
{
|
||||
page: 'guide.md',
|
||||
pageData: {} as any,
|
||||
hasCustom404: true,
|
||||
context: {
|
||||
content: '<main>Guide</main>',
|
||||
vpSocialIcons: [],
|
||||
customCallback: () => undefined
|
||||
} as any
|
||||
}
|
||||
]
|
||||
})
|
||||
).toThrow(/SSGContext must be structured-cloneable/)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,57 @@
|
||||
import type { MarkdownOptions } from 'node/markdown/markdown'
|
||||
import { highlight } from 'node/markdown/plugins/highlight'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
describe('persistent Shiki highlight cache', () => {
|
||||
let root: string | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
root = undefined
|
||||
}
|
||||
})
|
||||
|
||||
test('reuses highlighted HTML without initializing a second renderer', async () => {
|
||||
root = await mkdtemp(path.join(tmpdir(), 'vitepress-shiki-cache-'))
|
||||
|
||||
let setupCalls = 0
|
||||
let failIfInitialized = false
|
||||
const options: MarkdownOptions = {
|
||||
shikiCacheKey: 'persistent-cache-test-v1',
|
||||
async shikiSetup() {
|
||||
setupCalls++
|
||||
if (failIfInitialized) {
|
||||
throw new Error('the persistent cache was not used')
|
||||
}
|
||||
}
|
||||
}
|
||||
const logger = { warn: vi.fn() }
|
||||
|
||||
const [firstHighlight, disposeFirst] = await highlight(
|
||||
'github-light',
|
||||
options,
|
||||
logger,
|
||||
root
|
||||
)
|
||||
const first = await firstHighlight('const persistent = true', 'js', '{1}')
|
||||
disposeFirst()
|
||||
|
||||
failIfInitialized = true
|
||||
const [secondHighlight, disposeSecond] = await highlight(
|
||||
'github-light',
|
||||
options,
|
||||
logger,
|
||||
root
|
||||
)
|
||||
const second = await secondHighlight('const persistent = true', 'js', '{1}')
|
||||
disposeSecond()
|
||||
|
||||
expect(second).toBe(first)
|
||||
expect(second).toContain('const')
|
||||
expect(setupCalls).toBe(1)
|
||||
expect(logger.warn).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,264 @@
|
||||
import { PageArtifactStore } from 'node/pageArtifacts'
|
||||
import type { MarkdownCompileResult } from 'node/markdownToVue'
|
||||
import { mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
|
||||
describe('PageArtifactStore', () => {
|
||||
let root: string | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
root = undefined
|
||||
}
|
||||
})
|
||||
|
||||
async function createRoot() {
|
||||
root = await mkdtemp(path.join(tmpdir(), 'vitepress-page-artifacts-'))
|
||||
return root
|
||||
}
|
||||
|
||||
test('persists artifacts and non-JSON page-data values across store instances', async () => {
|
||||
const cacheDir = await createRoot()
|
||||
const store = new PageArtifactStore(cacheDir, {
|
||||
namespace: 'site-config-v1'
|
||||
})
|
||||
const published = new Date('2025-01-02T03:04:05.000Z')
|
||||
const artifact = createArtifact({
|
||||
pageData: {
|
||||
...createArtifact().pageData,
|
||||
frontmatter: { published, optional: undefined }
|
||||
}
|
||||
})
|
||||
|
||||
await store.put('docs/page.md', '# Page', artifact)
|
||||
await store.flush()
|
||||
|
||||
const restored = new PageArtifactStore(cacheDir, {
|
||||
namespace: 'site-config-v1'
|
||||
})
|
||||
const restoredArtifact = await restored.get('./docs/page.md', '# Page')
|
||||
expect(restoredArtifact).toEqual(artifact)
|
||||
expect(restoredArtifact?.pageData.frontmatter.published).toBeInstanceOf(
|
||||
Date
|
||||
)
|
||||
expect(
|
||||
Object.hasOwn(restoredArtifact?.pageData.frontmatter ?? {}, 'optional')
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('invalidates on source, namespace, and include dependency changes', async () => {
|
||||
const cacheDir = await createRoot()
|
||||
const include = path.join(cacheDir, 'shared.md')
|
||||
await writeFile(include, 'first include')
|
||||
|
||||
const store = new PageArtifactStore(cacheDir, { namespace: 'routes-v1' })
|
||||
await store.put(
|
||||
'page.md',
|
||||
'# Page',
|
||||
createArtifact({ includes: [include] })
|
||||
)
|
||||
await store.flush()
|
||||
|
||||
await expect(
|
||||
new PageArtifactStore(cacheDir, { namespace: 'routes-v1' }).get(
|
||||
'page.md',
|
||||
'# Changed'
|
||||
)
|
||||
).resolves.toBeUndefined()
|
||||
await expect(
|
||||
new PageArtifactStore(cacheDir, { namespace: 'routes-v2' }).get(
|
||||
'page.md',
|
||||
'# Page'
|
||||
)
|
||||
).resolves.toBeUndefined()
|
||||
|
||||
await writeFile(include, 'changed include')
|
||||
await expect(
|
||||
new PageArtifactStore(cacheDir, { namespace: 'routes-v1' }).get(
|
||||
'page.md',
|
||||
'# Page'
|
||||
)
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test('deduplicates concurrent compilation and identical CAS objects', async () => {
|
||||
const cacheDir = await createRoot()
|
||||
const store = new PageArtifactStore(cacheDir, { namespace: 'dedup' })
|
||||
const artifact = createArtifact()
|
||||
const compile = vi.fn(async () => {
|
||||
await Promise.resolve()
|
||||
return artifact
|
||||
})
|
||||
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: 4 }, () =>
|
||||
store.getOrCreate('page.md', '# Page', compile)
|
||||
)
|
||||
)
|
||||
|
||||
expect(compile).toHaveBeenCalledTimes(1)
|
||||
expect(results).toEqual([artifact, artifact, artifact, artifact])
|
||||
|
||||
// The page key belongs to the manifest, not to the immutable object. Two
|
||||
// entries with byte-identical output therefore share one object file.
|
||||
await store.put('alias.md', '# Alias', artifact)
|
||||
await store.flush()
|
||||
|
||||
const objectsDir = path.join(
|
||||
cacheDir,
|
||||
'vitepress-page-artifacts',
|
||||
'objects'
|
||||
)
|
||||
const shards = await readdir(objectsDir)
|
||||
const objectFiles = (
|
||||
await Promise.all(
|
||||
shards.map((shard) => readdir(path.join(objectsDir, shard)))
|
||||
)
|
||||
).flat()
|
||||
expect(objectFiles).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('shares content bodies across route-specific page overlays', async () => {
|
||||
const cacheDir = await createRoot()
|
||||
const store = new PageArtifactStore(cacheDir, {
|
||||
namespace: 'cross-version-body'
|
||||
})
|
||||
const artifact = createArtifact()
|
||||
await store.put('1.0/page.md', '# Page', artifact)
|
||||
await store.put('2.0/page.md', '# Page', {
|
||||
...artifact,
|
||||
pageData: {
|
||||
...artifact.pageData,
|
||||
relativePath: '2.0/page.md',
|
||||
filePath: '2.0/page.md'
|
||||
}
|
||||
})
|
||||
await store.flush()
|
||||
|
||||
const artifactRoot = path.join(cacheDir, 'vitepress-page-artifacts')
|
||||
expect(await countShardedFiles(path.join(artifactRoot, 'objects'))).toBe(2)
|
||||
expect(await countShardedFiles(path.join(artifactRoot, 'bodies'))).toBe(1)
|
||||
})
|
||||
|
||||
test('retains physical-module eligibility in compact page metadata', async () => {
|
||||
const cacheDir = await createRoot()
|
||||
const store = new PageArtifactStore(cacheDir, {
|
||||
namespace: 'source-module-identity'
|
||||
})
|
||||
const artifact = createArtifact({
|
||||
staticPage: undefined,
|
||||
requiresSourceModuleIdentity: true
|
||||
})
|
||||
|
||||
await store.put('styled.md', '# Styled', artifact)
|
||||
|
||||
await expect(store.getCurrentMetadata('styled.md')).resolves.toEqual({
|
||||
staticPage: false,
|
||||
requiresSourceModuleIdentity: true
|
||||
})
|
||||
})
|
||||
|
||||
test('persists pre-hook artifacts and finalizes them once per build', async () => {
|
||||
const cacheDir = await createRoot()
|
||||
const compile = vi.fn(async () => createArtifact())
|
||||
const coldFinalize = vi.fn(async (artifact: MarkdownCompileResult) => ({
|
||||
...artifact,
|
||||
pageData: { ...artifact.pageData, title: 'cold build' }
|
||||
}))
|
||||
const cold = new PageArtifactStore(cacheDir, {
|
||||
namespace: 'page-data-hooks'
|
||||
})
|
||||
|
||||
const [coldFirst, coldSecond] = await Promise.all([
|
||||
cold.getOrCreate('page.md', '# Page', compile, coldFinalize),
|
||||
cold.getOrCreate('page.md', '# Page', compile, coldFinalize)
|
||||
])
|
||||
expect(compile).toHaveBeenCalledTimes(1)
|
||||
expect(coldFinalize).toHaveBeenCalledTimes(1)
|
||||
expect(coldFirst.pageData.title).toBe('cold build')
|
||||
expect(coldSecond.pageData.title).toBe('cold build')
|
||||
expect((await cold.getCurrent('page.md'))?.pageData.title).toBe(
|
||||
'cold build'
|
||||
)
|
||||
await cold.flush()
|
||||
|
||||
const warmCompile = vi.fn(async () => {
|
||||
throw new Error('Markdown must not run on a warm artifact hit')
|
||||
})
|
||||
const warmFinalize = vi.fn(async (artifact: MarkdownCompileResult) => ({
|
||||
...artifact,
|
||||
pageData: {
|
||||
...artifact.pageData,
|
||||
title: `${artifact.pageData.title}:warm build`
|
||||
}
|
||||
}))
|
||||
const warm = new PageArtifactStore(cacheDir, {
|
||||
namespace: 'page-data-hooks'
|
||||
})
|
||||
|
||||
const warmFirst = await warm.getOrCreate(
|
||||
'page.md',
|
||||
'# Page',
|
||||
warmCompile,
|
||||
warmFinalize
|
||||
)
|
||||
const warmSecond = await warm.getOrCreate(
|
||||
'page.md',
|
||||
'# Page',
|
||||
warmCompile,
|
||||
warmFinalize
|
||||
)
|
||||
|
||||
expect(warmCompile).not.toHaveBeenCalled()
|
||||
expect(warmFinalize).toHaveBeenCalledTimes(1)
|
||||
// The warm hook starts from the persistent pre-hook page data. It must not
|
||||
// receive the previous build's transformed result.
|
||||
expect(warmFirst.pageData.title).toBe('Page:warm build')
|
||||
expect(warmSecond.pageData.title).toBe('Page:warm build')
|
||||
expect((await warm.getCurrent('page.md'))?.pageData.title).toBe(
|
||||
'Page:warm build'
|
||||
)
|
||||
})
|
||||
|
||||
test('turns a read-only cache miss into a coordinator error', async () => {
|
||||
const cacheDir = await createRoot()
|
||||
const store = new PageArtifactStore(cacheDir, {
|
||||
namespace: 'render-worker',
|
||||
readOnly: true
|
||||
})
|
||||
|
||||
await expect(store.getOrCreate('missing.md', '# Missing')).rejects.toThrow(
|
||||
'The coordinator must compile page artifacts before starting render workers.'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
async function countShardedFiles(root: string): Promise<number> {
|
||||
const shards = await readdir(root)
|
||||
return (
|
||||
await Promise.all(shards.map((shard) => readdir(path.join(root, shard))))
|
||||
).flat().length
|
||||
}
|
||||
|
||||
function createArtifact(
|
||||
overrides: Partial<MarkdownCompileResult> = {}
|
||||
): MarkdownCompileResult {
|
||||
return {
|
||||
vueSrc: '<template><div><h1>Page</h1></div></template>',
|
||||
html: '<h1>Page</h1>',
|
||||
pageData: {
|
||||
title: 'Page',
|
||||
description: '',
|
||||
frontmatter: {},
|
||||
headers: [],
|
||||
relativePath: 'page.md',
|
||||
filePath: 'page.md'
|
||||
},
|
||||
deadLinks: [],
|
||||
includes: [],
|
||||
staticPage: true,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,140 @@
|
||||
import { resolveConfig } from 'node/config'
|
||||
import { PageArtifactStore } from 'node/pageArtifacts'
|
||||
import { createVitePressPlugin } from 'node/plugin'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { normalizePath, type Plugin } from 'vite'
|
||||
|
||||
describe('node/plugin coordinator client', () => {
|
||||
let root: string | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
root = undefined
|
||||
}
|
||||
})
|
||||
|
||||
test('initializes Markdown and preloads resolved pages through the client graph', async () => {
|
||||
root = await mkdtemp(path.join(tmpdir(), 'vitepress-client-preload-'))
|
||||
await Promise.all([
|
||||
writeFile(path.join(root, 'one.md'), '# One\n'),
|
||||
writeFile(path.join(root, 'two.md'), '# Two\n')
|
||||
])
|
||||
|
||||
const siteConfig = await resolveConfig(root, 'build', 'production')
|
||||
const configureMarkdown = vi.fn()
|
||||
const userPostBuildStart = vi.fn()
|
||||
const userPlugin: Plugin = {
|
||||
name: 'test:user-post-build-start',
|
||||
enforce: 'post',
|
||||
buildStart: {
|
||||
order: 'post',
|
||||
handler: userPostBuildStart
|
||||
}
|
||||
}
|
||||
siteConfig.markdown = { cache: false, config: configureMarkdown }
|
||||
siteConfig.vite = { plugins: [userPlugin] }
|
||||
siteConfig.buildConcurrency = 1
|
||||
const store = new PageArtifactStore(siteConfig.cacheDir, {
|
||||
namespace: 'client-preload'
|
||||
})
|
||||
const plugins = await createVitePressPlugin(
|
||||
siteConfig,
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
coordinatorClient: true,
|
||||
pageArtifactStore: store,
|
||||
skipGitScan: true
|
||||
}
|
||||
)
|
||||
const vitePressPlugin = plugins[0] as Plugin
|
||||
const configResolved = getHookHandler(vitePressPlugin.configResolved)
|
||||
await configResolved.call(undefined, {
|
||||
base: '/',
|
||||
command: 'build',
|
||||
publicDir: siteConfig.publicDir
|
||||
} as any)
|
||||
|
||||
// This lifecycle still runs on an all-warm build while the highlighter
|
||||
// remains lazy until a Markdown cache miss actually needs it.
|
||||
expect(configureMarkdown).toHaveBeenCalledTimes(1)
|
||||
|
||||
const preloadPlugin = plugins.at(-1) as Plugin
|
||||
expect(preloadPlugin.name).toBe('vitepress:coordinator-page-preload')
|
||||
expect(preloadPlugin.enforce).toBe('post')
|
||||
expect(plugins.indexOf(userPlugin)).toBeLessThan(
|
||||
plugins.indexOf(preloadPlugin)
|
||||
)
|
||||
const buildStart = preloadPlugin.buildStart as unknown as {
|
||||
order: string
|
||||
sequential: boolean
|
||||
handler: (...args: any[]) => Promise<void>
|
||||
}
|
||||
expect(buildStart.order).toBe('post')
|
||||
expect(buildStart.sequential).toBe(true)
|
||||
|
||||
let activeLoads = 0
|
||||
let peakLoads = 0
|
||||
const resolve = vi.fn(async (id: string) => ({ id }))
|
||||
const load = vi.fn(
|
||||
async (_options: { id: string; resolveDependencies: boolean }) => {
|
||||
expect(userPostBuildStart).toHaveBeenCalledTimes(1)
|
||||
activeLoads++
|
||||
peakLoads = Math.max(peakLoads, activeLoads)
|
||||
await Promise.resolve()
|
||||
activeLoads--
|
||||
return {} as any
|
||||
}
|
||||
)
|
||||
await getHookHandler(userPlugin.buildStart).call({})
|
||||
await buildStart.handler.call({ resolve, load })
|
||||
|
||||
const expectedIds = siteConfig.pages.map((page) =>
|
||||
normalizePath(path.resolve(siteConfig.srcDir, page))
|
||||
)
|
||||
expect(resolve.mock.calls.map(([id]) => id)).toEqual(expectedIds)
|
||||
expect(load.mock.calls.map(([options]) => options)).toEqual(
|
||||
expectedIds.map((id) => ({ id, resolveDependencies: true }))
|
||||
)
|
||||
expect(peakLoads).toBe(1)
|
||||
})
|
||||
|
||||
test('does not let an isolated SSR phase replace the client public directory', async () => {
|
||||
root = await mkdtemp(path.join(tmpdir(), 'vitepress-ssr-public-dir-'))
|
||||
await writeFile(path.join(root, 'index.md'), '# Page\n')
|
||||
|
||||
const siteConfig = await resolveConfig(root, 'build', 'production')
|
||||
const clientPublicDir = siteConfig.publicDir
|
||||
const plugins = await createVitePressPlugin(
|
||||
siteConfig,
|
||||
true,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{ isSsrBatch: true, skipGitScan: true }
|
||||
)
|
||||
const vitePressPlugin = plugins[0] as Plugin
|
||||
const configResolved = getHookHandler(vitePressPlugin.configResolved)
|
||||
await configResolved.call(undefined, {
|
||||
base: '/',
|
||||
command: 'build',
|
||||
publicDir: path.join(root, 'runtime-public')
|
||||
} as any)
|
||||
|
||||
expect(siteConfig.publicDir).toBe(clientPublicDir)
|
||||
})
|
||||
})
|
||||
|
||||
function getHookHandler<T extends (...args: any[]) => any>(
|
||||
hook: T | { handler: T } | undefined
|
||||
): T {
|
||||
if (!hook) throw new Error('Expected plugin hook.')
|
||||
return typeof hook === 'function' ? hook : hook.handler
|
||||
}
|
||||
@ -0,0 +1,176 @@
|
||||
import { createVueDescriptorMemoryPlugin } from 'node/plugins/vueDescriptorMemory'
|
||||
import type { Plugin } from 'vite'
|
||||
|
||||
describe('node/plugins/vueDescriptorMemory', () => {
|
||||
test('compacts tracked compiler results without mutating the shared compiler', () => {
|
||||
const { compiler, parseCacheClear } = createCompiler()
|
||||
const vuePlugin = createVuePlugin(compiler)
|
||||
const plugin = createVueDescriptorMemoryPlugin(vuePlugin)
|
||||
const originalParse = compiler.parse
|
||||
const originalCompileScript = compiler.compileScript
|
||||
|
||||
getBuildStart(plugin).call({})
|
||||
|
||||
const facade = (vuePlugin as any).api.options.compiler
|
||||
expect(facade).not.toBe(compiler)
|
||||
expect(compiler.parse).toBe(originalParse)
|
||||
expect(compiler.compileScript).toBe(originalCompileScript)
|
||||
|
||||
const parsed = facade.parse('<template>large source</template>', {
|
||||
filename: '/one.md'
|
||||
})
|
||||
const script = facade.compileScript(parsed.descriptor)
|
||||
expect(plugin.api.retainedFiles).toBe(1)
|
||||
|
||||
getHook(plugin.buildEnd).call({}, undefined)
|
||||
|
||||
expect(plugin.api.retainedFiles).toBe(0)
|
||||
expect(parsed.descriptor.source).toBe('')
|
||||
expect(parsed.descriptor.template.content).toBe('')
|
||||
expect(parsed.descriptor.template.ast).toBeUndefined()
|
||||
expect(script.content).toBe('')
|
||||
expect(script.scriptAst).toBeUndefined()
|
||||
expect(parseCacheClear).toHaveBeenCalledTimes(1)
|
||||
expect((vuePlugin as any).api.options.compiler).toBe(facade)
|
||||
|
||||
getHook(plugin.closeBundle).call({})
|
||||
expect((vuePlugin as any).api.options.compiler).toBe(compiler)
|
||||
expect(compiler.parse).toBe(originalParse)
|
||||
expect(compiler.compileScript).toBe(originalCompileScript)
|
||||
})
|
||||
|
||||
test('isolates concurrent plugin instances that share compiler-sfc', () => {
|
||||
const { compiler } = createCompiler()
|
||||
const vueA = createVuePlugin(compiler)
|
||||
const vueB = createVuePlugin(compiler)
|
||||
const pluginA = createVueDescriptorMemoryPlugin(vueA)
|
||||
const pluginB = createVueDescriptorMemoryPlugin(vueB)
|
||||
|
||||
getBuildStart(pluginA).call({})
|
||||
getBuildStart(pluginB).call({})
|
||||
|
||||
const facadeA = (vueA as any).api.options.compiler
|
||||
const facadeB = (vueB as any).api.options.compiler
|
||||
expect(facadeA).not.toBe(facadeB)
|
||||
expect(compiler.parse).not.toBe(facadeA.parse)
|
||||
expect(compiler.parse).not.toBe(facadeB.parse)
|
||||
|
||||
const descriptorA = facadeA.parse('source a', {
|
||||
filename: '/shared.md'
|
||||
}).descriptor
|
||||
const descriptorB = facadeB.parse('source b', {
|
||||
filename: '/shared.md'
|
||||
}).descriptor
|
||||
|
||||
pluginA.api.release(['/shared.md'])
|
||||
expect(descriptorA.source).toBe('')
|
||||
expect(descriptorB.source).toBe('source b')
|
||||
|
||||
getHook(pluginA.closeBundle).call({})
|
||||
expect((vueA as any).api.options.compiler).toBe(compiler)
|
||||
expect((vueB as any).api.options.compiler).toBe(facadeB)
|
||||
|
||||
getHook(pluginB.closeBundle).call({})
|
||||
expect((vueB as any).api.options.compiler).toBe(compiler)
|
||||
})
|
||||
|
||||
test('leases compiler-sfc parse-cache results across concurrent builds', () => {
|
||||
const { compiler } = createCompiler()
|
||||
const sharedDescriptor = compiler.parse('shared source', {
|
||||
filename: '/shared.md'
|
||||
}).descriptor
|
||||
compiler.parse.mockReturnValue({ descriptor: sharedDescriptor })
|
||||
const vueA = createVuePlugin(compiler)
|
||||
const vueB = createVuePlugin(compiler)
|
||||
const pluginA = createVueDescriptorMemoryPlugin(vueA)
|
||||
const pluginB = createVueDescriptorMemoryPlugin(vueB)
|
||||
|
||||
getBuildStart(pluginA).call({})
|
||||
getBuildStart(pluginB).call({})
|
||||
;(vueA as any).api.options.compiler.parse('shared source', {
|
||||
filename: '/shared.md'
|
||||
})
|
||||
;(vueB as any).api.options.compiler.parse('shared source', {
|
||||
filename: '/shared.md'
|
||||
})
|
||||
|
||||
pluginA.api.release(['/shared.md'])
|
||||
expect(sharedDescriptor.source).toBe('shared source')
|
||||
|
||||
pluginB.api.release(['/shared.md'])
|
||||
expect(sharedDescriptor.source).toBe('')
|
||||
|
||||
getHook(pluginA.closeBundle).call({})
|
||||
getHook(pluginB.closeBundle).call({})
|
||||
})
|
||||
|
||||
test('restores its compiler facade when graph construction fails', () => {
|
||||
const { compiler } = createCompiler()
|
||||
const vuePlugin = createVuePlugin(compiler)
|
||||
const plugin = createVueDescriptorMemoryPlugin(vuePlugin)
|
||||
|
||||
getBuildStart(plugin).call({})
|
||||
expect((vuePlugin as any).api.options.compiler).not.toBe(compiler)
|
||||
|
||||
getHook(plugin.buildEnd).call({}, new Error('build failed'))
|
||||
expect((vuePlugin as any).api.options.compiler).toBe(compiler)
|
||||
})
|
||||
})
|
||||
|
||||
function createCompiler() {
|
||||
const parseCacheClear = vi.fn()
|
||||
const compiler = {
|
||||
parse: vi.fn((source: string, options: { filename: string }) => ({
|
||||
descriptor: {
|
||||
filename: options.filename,
|
||||
source,
|
||||
template: {
|
||||
content: source,
|
||||
ast: { source },
|
||||
map: { source },
|
||||
loc: { source }
|
||||
},
|
||||
script: null,
|
||||
scriptSetup: null,
|
||||
styles: [],
|
||||
customBlocks: []
|
||||
}
|
||||
})),
|
||||
compileScript: vi.fn((descriptor: { filename: string }) => ({
|
||||
content: `compiled ${descriptor.filename}`,
|
||||
scriptAst: { filename: descriptor.filename },
|
||||
scriptSetupAst: { filename: descriptor.filename },
|
||||
deps: [descriptor.filename],
|
||||
imports: { value: true },
|
||||
bindings: { value: true }
|
||||
})),
|
||||
parseCache: { clear: parseCacheClear }
|
||||
}
|
||||
return { compiler, parseCacheClear }
|
||||
}
|
||||
|
||||
function createVuePlugin(
|
||||
compiler: ReturnType<typeof createCompiler>['compiler']
|
||||
): Plugin {
|
||||
return {
|
||||
name: 'vite:vue',
|
||||
api: {
|
||||
options: { compiler }
|
||||
}
|
||||
} as Plugin
|
||||
}
|
||||
|
||||
function getBuildStart(plugin: Plugin): (...args: any[]) => any {
|
||||
const hook = plugin.buildStart
|
||||
if (!hook || typeof hook === 'function') {
|
||||
throw new Error('Expected an object buildStart hook.')
|
||||
}
|
||||
return hook.handler
|
||||
}
|
||||
|
||||
function getHook<T extends (...args: any[]) => any>(
|
||||
hook: T | { handler: T } | undefined
|
||||
): T {
|
||||
if (!hook) throw new Error('Expected plugin hook.')
|
||||
return typeof hook === 'function' ? hook : hook.handler
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import {
|
||||
cacheAllGitTimestamps,
|
||||
getGitTimestamp
|
||||
} from 'node/utils/getGitTimestamp'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
test('development cache misses are retried after a file is committed', async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), 'vitepress-git-'))
|
||||
const page = path.join(root, 'page.md')
|
||||
|
||||
try {
|
||||
await execFileAsync('git', ['init'], { cwd: root })
|
||||
await writeFile(page, '# Page')
|
||||
await cacheAllGitTimestamps(root)
|
||||
|
||||
expect(await getGitTimestamp(page)).toBe(0)
|
||||
|
||||
await execFileAsync('git', ['add', 'page.md'], { cwd: root })
|
||||
await execFileAsync(
|
||||
'git',
|
||||
[
|
||||
'-c',
|
||||
'user.name=VitePress Test',
|
||||
'-c',
|
||||
'user.email=vitepress@example.com',
|
||||
'commit',
|
||||
'-m',
|
||||
'add page'
|
||||
],
|
||||
{ cwd: root }
|
||||
)
|
||||
|
||||
expect(await getGitTimestamp(page)).toBeGreaterThan(0)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@ -1,12 +1,2 @@
|
||||
// entry for SSR
|
||||
import { renderToString } from 'vue/server-renderer'
|
||||
import type { SSGContext } from '../shared'
|
||||
import { createApp } from './index'
|
||||
|
||||
export async function render(path: string) {
|
||||
const { app, router } = await createApp()
|
||||
await router.go(path)
|
||||
const ctx: SSGContext = { content: '', vpSocialIcons: new Set<string>() }
|
||||
ctx.content = await renderToString(app, ctx)
|
||||
return ctx
|
||||
}
|
||||
// legacy full-bundle SSR entry
|
||||
export { render } from './ssrRuntime'
|
||||
|
||||
@ -0,0 +1,65 @@
|
||||
import { createStaticVNode, defineComponent } from 'vue'
|
||||
import { renderToString } from 'vue/server-renderer'
|
||||
import type { PageData, SSGContext } from '../shared'
|
||||
import { createApp, type PageModuleLoader } from './index'
|
||||
|
||||
type PageModule = Exclude<Awaited<ReturnType<PageModuleLoader>>, null>
|
||||
|
||||
export interface StaticPagePayload {
|
||||
html: string
|
||||
pageData: PageData
|
||||
}
|
||||
|
||||
export type SsrPagePayload = PageModule | StaticPagePayload | null
|
||||
|
||||
function isStaticPagePayload(
|
||||
payload: Exclude<SsrPagePayload, null>
|
||||
): payload is StaticPagePayload {
|
||||
return !('default' in payload)
|
||||
}
|
||||
|
||||
function createStaticPageModule(payload: StaticPagePayload): PageModule {
|
||||
const component = defineComponent({
|
||||
name: payload.pageData.relativePath,
|
||||
setup() {
|
||||
return () => createStaticVNode(`<div>${payload.html}</div>`, 1)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
default: component,
|
||||
__pageData: payload.pageData
|
||||
}
|
||||
}
|
||||
|
||||
async function renderWithLoader(
|
||||
path: string,
|
||||
loadPageModule?: PageModuleLoader
|
||||
) {
|
||||
const { app, router } = await createApp(loadPageModule)
|
||||
await router.go(path)
|
||||
const ctx: SSGContext = { content: '', vpSocialIcons: new Set<string>() }
|
||||
ctx.content = await renderToString(app, ctx)
|
||||
return ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* Render through the production route loader. This keeps the legacy full SSR
|
||||
* bundle entry working while the artifact renderer uses `renderPage` below.
|
||||
*/
|
||||
export function render(path: string) {
|
||||
return renderWithLoader(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an already-loaded page module, or a conservative static Markdown
|
||||
* payload, through the shared application and theme runtime.
|
||||
*/
|
||||
export function renderPage(path: string, payload: SsrPagePayload) {
|
||||
const pageModule =
|
||||
payload && isStaticPagePayload(payload)
|
||||
? createStaticPageModule(payload)
|
||||
: payload
|
||||
|
||||
return renderWithLoader(path, () => pageModule)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,190 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import pMap from 'p-map'
|
||||
import type { SiteConfig } from '../config'
|
||||
import { disposeMdItInstance } from '../markdown/markdown'
|
||||
import { clearCache } from '../markdownToVue'
|
||||
import { slash, type HeadConfig, type SSGContext } from '../shared'
|
||||
import { deserializeFunctions, serializeFunctions } from '../utils/fnSerialize'
|
||||
import { nativeImport } from '../utils/nativeImport'
|
||||
import { createRenderMetadata, renderPage, type RenderMetadata } from './render'
|
||||
|
||||
export function disposeBuildCaches(): void {
|
||||
clearBuildCaches()
|
||||
disposeMdItInstance()
|
||||
}
|
||||
|
||||
export function clearBuildCaches(): void {
|
||||
clearCache()
|
||||
}
|
||||
|
||||
function createAdditionalHeadTags(
|
||||
renderMetadata: RenderMetadata
|
||||
): HeadConfig[] {
|
||||
const additionalHeadTags: HeadConfig[] = []
|
||||
if (renderMetadata.isDefaultTheme) {
|
||||
const fontURL = renderMetadata.assets.find((file) =>
|
||||
/inter-roman-latin\.[\w-]+\.woff2/.test(file)
|
||||
)
|
||||
if (fontURL) {
|
||||
additionalHeadTags.push([
|
||||
'link',
|
||||
{
|
||||
rel: 'preload',
|
||||
href: fontURL,
|
||||
as: 'font',
|
||||
type: 'font/woff2',
|
||||
crossorigin: ''
|
||||
}
|
||||
])
|
||||
}
|
||||
}
|
||||
return additionalHeadTags
|
||||
}
|
||||
|
||||
async function generateMetadataScript(
|
||||
pageToHashMap: Record<string, string>,
|
||||
config: SiteConfig
|
||||
): Promise<{ html: string; inHead: boolean }> {
|
||||
if (config.mpa) {
|
||||
return { html: '', inHead: false }
|
||||
}
|
||||
|
||||
// We embed the hash map and site config strings into each page directly
|
||||
// so that it doesn't alter the main chunk's hash on every build.
|
||||
// It's also embedded as a string and JSON.parsed from the client because
|
||||
// it's faster than embedding as JS object literal.
|
||||
const hashMapString = JSON.stringify(JSON.stringify(pageToHashMap))
|
||||
const siteDataString = JSON.stringify(
|
||||
JSON.stringify(serializeFunctions({ ...config.site, head: [] }))
|
||||
)
|
||||
|
||||
const metadataContent = `window.__VP_HASH_MAP__=JSON.parse(${hashMapString});${
|
||||
siteDataString.includes('_vp-fn_')
|
||||
? `${deserializeFunctions};window.__VP_SITE_DATA__=deserializeFunctions(JSON.parse(${siteDataString}));`
|
||||
: `window.__VP_SITE_DATA__=JSON.parse(${siteDataString});`
|
||||
}`
|
||||
|
||||
const metadataFile = path.join(
|
||||
config.assetsDir,
|
||||
'chunks',
|
||||
`metadata.${createHash('sha256')
|
||||
.update(metadataContent)
|
||||
.digest('hex')
|
||||
.slice(0, 8)}.js`
|
||||
)
|
||||
|
||||
const resolvedMetadataFile = path.join(config.outDir, metadataFile)
|
||||
const metadataFileURL = slash(`${config.site.base}${metadataFile}`)
|
||||
|
||||
await mkdir(path.dirname(resolvedMetadataFile), { recursive: true })
|
||||
await writeFile(resolvedMetadataFile, metadataContent)
|
||||
|
||||
return {
|
||||
html: `<script type="module" src="${metadataFileURL}"></script>`,
|
||||
inHead: true
|
||||
}
|
||||
}
|
||||
|
||||
export async function prepareRenderInputs(
|
||||
siteConfig: SiteConfig,
|
||||
clientResult: Parameters<typeof createRenderMetadata>[1],
|
||||
serverResult: Parameters<typeof createRenderMetadata>[2],
|
||||
pageToHashMap: Record<string, string>
|
||||
) {
|
||||
const renderMetadata = createRenderMetadata(
|
||||
siteConfig,
|
||||
clientResult,
|
||||
serverResult
|
||||
)
|
||||
return {
|
||||
renderMetadata,
|
||||
additionalHeadTags: createAdditionalHeadTags(renderMetadata),
|
||||
metadataScript: await generateMetadataScript(pageToHashMap, siteConfig)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRenderer(tempDir: string) {
|
||||
const { render } = await nativeImport(path.join(tempDir, 'app.js'))
|
||||
return render as (path: string) => Promise<SSGContext>
|
||||
}
|
||||
|
||||
export async function renderPages(
|
||||
render: (path: string) => Promise<SSGContext>,
|
||||
siteConfig: SiteConfig,
|
||||
serverTempDir: string,
|
||||
pages: string[],
|
||||
renderMetadata: RenderMetadata,
|
||||
pageToHashMap: Record<string, string>,
|
||||
metadataScript: { html: string; inHead: boolean },
|
||||
additionalHeadTags: HeadConfig[],
|
||||
usedIcons: Set<string>
|
||||
): Promise<void> {
|
||||
await pMap(
|
||||
pages,
|
||||
async (page) => {
|
||||
await renderPage(
|
||||
render,
|
||||
siteConfig,
|
||||
siteConfig.rewrites.map[page] || page,
|
||||
renderMetadata,
|
||||
pageToHashMap,
|
||||
metadataScript,
|
||||
additionalHeadTags,
|
||||
usedIcons,
|
||||
serverTempDir
|
||||
)
|
||||
},
|
||||
{ concurrency: siteConfig.buildConcurrency }
|
||||
)
|
||||
}
|
||||
|
||||
export function createRouteDigest(siteConfig: SiteConfig): string {
|
||||
const hash = createHash('sha256')
|
||||
const add = (value: string | undefined) => {
|
||||
if (value === undefined) {
|
||||
hash.update('-1:')
|
||||
} else {
|
||||
hash.update(`${Buffer.byteLength(value)}:`)
|
||||
hash.update(value)
|
||||
}
|
||||
}
|
||||
const sortEntries = (entries: [string, string | undefined][]) =>
|
||||
entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
||||
|
||||
hash.update('pages:')
|
||||
siteConfig.pages.forEach(add)
|
||||
|
||||
hash.update(`dynamicRoutes:${siteConfig.dynamicRoutes.length}:`)
|
||||
for (const route of siteConfig.dynamicRoutes) {
|
||||
add(route.route)
|
||||
add(route.path)
|
||||
add(route.fullPath)
|
||||
add(route.loaderPath)
|
||||
const params = sortEntries(Object.entries(route.params))
|
||||
hash.update(`params:${params.length}:`)
|
||||
for (const [key, value] of params) {
|
||||
add(key)
|
||||
add(value)
|
||||
}
|
||||
add(route.content)
|
||||
}
|
||||
|
||||
hash.update('rewrites.map:')
|
||||
for (const [key, value] of sortEntries(
|
||||
Object.entries(siteConfig.rewrites.map)
|
||||
)) {
|
||||
add(key)
|
||||
add(value)
|
||||
}
|
||||
hash.update('rewrites.inv:')
|
||||
for (const [key, value] of sortEntries(
|
||||
Object.entries(siteConfig.rewrites.inv)
|
||||
)) {
|
||||
add(key)
|
||||
add(value)
|
||||
}
|
||||
|
||||
return hash.digest('hex')
|
||||
}
|
||||
@ -0,0 +1,412 @@
|
||||
import type { Plugin, Rolldown } from 'vite'
|
||||
|
||||
export interface SsrBatchPlan {
|
||||
offset: number
|
||||
pages: string[]
|
||||
}
|
||||
|
||||
const SSR_PAGE_UNBUNDLED_HOOKS = [
|
||||
'moduleParsed',
|
||||
'resolveDynamicImport',
|
||||
'augmentChunkHash',
|
||||
'outputOptions',
|
||||
'renderChunk',
|
||||
'renderStart',
|
||||
'renderError',
|
||||
'writeBundle',
|
||||
'generateBundle',
|
||||
'banner',
|
||||
'footer',
|
||||
'intro',
|
||||
'outro'
|
||||
] as const
|
||||
const SSR_PAGE_OUTPUT_ADDONS = ['banner', 'footer', 'intro', 'outro'] as const
|
||||
const SSR_PAGE_CONTEXT_HOOKS = [
|
||||
'options',
|
||||
'buildStart',
|
||||
'resolveId',
|
||||
'load',
|
||||
'transform',
|
||||
'buildEnd',
|
||||
'closeBundle'
|
||||
] as const
|
||||
const SSR_PAGE_UNAVAILABLE_CONTEXT_METHODS = new Set<PropertyKey>([
|
||||
'emitFile',
|
||||
'getFileName',
|
||||
'getModuleIds'
|
||||
])
|
||||
const buildModeEnvironmentFacades = new WeakMap<object, object>()
|
||||
|
||||
type SsrPageUnbundledHook = (typeof SSR_PAGE_UNBUNDLED_HOOKS)[number]
|
||||
type PageUnbundledPlugin = {
|
||||
name?: string
|
||||
} & Partial<Record<SsrPageUnbundledHook, unknown>>
|
||||
|
||||
interface PageUnbundledHookViolation {
|
||||
hooks: SsrPageUnbundledHook[]
|
||||
name: string
|
||||
source: 'plugin' | 'output plugin' | 'output options'
|
||||
}
|
||||
|
||||
function isViteInternalPlugin(name: string): boolean {
|
||||
return (
|
||||
name === 'alias' ||
|
||||
name === 'vitepress' ||
|
||||
name.startsWith('vite:') ||
|
||||
name.startsWith('vitepress:') ||
|
||||
name.startsWith('builtin:') ||
|
||||
name.startsWith('native:')
|
||||
)
|
||||
}
|
||||
|
||||
function unbundledHooks(plugin: PageUnbundledPlugin): SsrPageUnbundledHook[] {
|
||||
return SSR_PAGE_UNBUNDLED_HOOKS.filter((hook) => plugin[hook] != null)
|
||||
}
|
||||
|
||||
function unsupportedPageContextMethod(
|
||||
pluginName: string,
|
||||
method: PropertyKey
|
||||
): never {
|
||||
throw new Error(
|
||||
`Vite plugin ${JSON.stringify(pluginName)} called this.${String(method)}() while compiling unbundled SSR page modules. ` +
|
||||
'SSR batching has no Rolldown output or complete bundle graph for this context method. Exclude the plugin from the unbundled SSR environment or disable ssrBuildBatchSize.'
|
||||
)
|
||||
}
|
||||
|
||||
function createBuildModeEnvironment(environment: object): object {
|
||||
const existing = buildModeEnvironmentFacades.get(environment)
|
||||
if (existing) return existing
|
||||
|
||||
const facade = new Proxy(environment, {
|
||||
get(target, property) {
|
||||
if (property === 'mode') return 'build'
|
||||
const value = Reflect.get(target, property, target)
|
||||
return typeof value === 'function' ? value.bind(target) : value
|
||||
},
|
||||
set(target, property, value) {
|
||||
if (property === 'mode') return value === 'build'
|
||||
return Reflect.set(target, property, value, target)
|
||||
}
|
||||
})
|
||||
buildModeEnvironmentFacades.set(environment, facade)
|
||||
return facade
|
||||
}
|
||||
|
||||
function createBuildModePluginContext(
|
||||
context: unknown,
|
||||
pluginName: string
|
||||
): unknown {
|
||||
if (!context || typeof context !== 'object') return context
|
||||
|
||||
const target = context as Record<PropertyKey, unknown>
|
||||
const environment = target.environment
|
||||
const buildEnvironment =
|
||||
environment && typeof environment === 'object'
|
||||
? createBuildModeEnvironment(environment)
|
||||
: environment
|
||||
const meta = target.meta
|
||||
const buildMeta =
|
||||
meta && typeof meta === 'object'
|
||||
? { ...(meta as object), watchMode: false }
|
||||
: meta
|
||||
|
||||
return new Proxy(target, {
|
||||
get(target, property) {
|
||||
if (property === 'environment') return buildEnvironment
|
||||
if (property === 'meta') return buildMeta
|
||||
// Rolldown's production context does not expose these serve-only APIs.
|
||||
// Hide Vite's runnable-environment additions so feature detection is
|
||||
// identical to a production plugin context.
|
||||
if (property === 'setAssetSource' || property === 'getWatchFiles') {
|
||||
return undefined
|
||||
}
|
||||
if (
|
||||
SSR_PAGE_UNAVAILABLE_CONTEXT_METHODS.has(property) &&
|
||||
property in target
|
||||
) {
|
||||
return () => unsupportedPageContextMethod(pluginName, property)
|
||||
}
|
||||
if (
|
||||
property === 'getCombinedSourcemap' &&
|
||||
typeof target._getCombinedSourcemap === 'function'
|
||||
) {
|
||||
return target._getCombinedSourcemap.bind(target)
|
||||
}
|
||||
|
||||
const value = Reflect.get(target, property, target)
|
||||
return typeof value === 'function' ? value.bind(target) : value
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function adaptPluginHookContext(hook: unknown, pluginName: string): unknown {
|
||||
const handler =
|
||||
typeof hook === 'function'
|
||||
? hook
|
||||
: hook && typeof hook === 'object'
|
||||
? (hook as { handler?: unknown }).handler
|
||||
: undefined
|
||||
if (typeof handler !== 'function') return hook
|
||||
|
||||
const wrapped = function (this: unknown, ...args: unknown[]) {
|
||||
return Reflect.apply(
|
||||
handler,
|
||||
createBuildModePluginContext(this, pluginName),
|
||||
args
|
||||
)
|
||||
}
|
||||
return typeof hook === 'function'
|
||||
? wrapped
|
||||
: { ...(hook as object), handler: wrapped }
|
||||
}
|
||||
|
||||
/**
|
||||
* A RunnableDevEnvironment supplies the transform/module graph needed by the
|
||||
* offline compiler, but its raw user contexts identify as dev/watch mode and
|
||||
* silently ignore Rolldown-only methods. Present production semantics to user
|
||||
* hooks and fail unsupported output/whole-graph access explicitly. Vite's own
|
||||
* plugins retain their native runnable-environment contexts.
|
||||
*
|
||||
* @internal Exported for focused pipeline tests.
|
||||
*/
|
||||
export function adaptSsrBatchPagePlugins(plugins: readonly Plugin[]): Plugin[] {
|
||||
return plugins.map((plugin) => {
|
||||
if (isViteInternalPlugin(plugin.name)) return plugin
|
||||
|
||||
// A Proxy cannot legally return a wrapped hook for a frozen plugin's
|
||||
// non-configurable property. Copy resolved values onto a loose facade so
|
||||
// frozen and class-based plugin objects remain supported.
|
||||
const adapted = Object.create(Object.getPrototypeOf(plugin)) as Plugin &
|
||||
Record<PropertyKey, unknown>
|
||||
for (const property of Reflect.ownKeys(plugin)) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(plugin, property)!
|
||||
Object.defineProperty(adapted, property, {
|
||||
configurable: true,
|
||||
enumerable: descriptor.enumerable,
|
||||
value: Reflect.get(plugin, property, plugin),
|
||||
writable: true
|
||||
})
|
||||
}
|
||||
for (const hook of SSR_PAGE_CONTEXT_HOOKS) {
|
||||
const value = Reflect.get(plugin, hook, plugin)
|
||||
if (value == null) continue
|
||||
Object.defineProperty(adapted, hook, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value: adaptPluginHookContext(value, plugin.name || '<anonymous>'),
|
||||
writable: true
|
||||
})
|
||||
}
|
||||
return adapted
|
||||
})
|
||||
}
|
||||
|
||||
async function collectOutputPlugins(
|
||||
option: Rolldown.OutputOptions['plugins'],
|
||||
collected: PageUnbundledPlugin[]
|
||||
): Promise<void> {
|
||||
const resolved = await option
|
||||
if (Array.isArray(resolved)) {
|
||||
for (const nested of resolved) {
|
||||
await collectOutputPlugins(nested, collected)
|
||||
}
|
||||
} else if (resolved && typeof resolved === 'object') {
|
||||
collected.push(resolved as PageUnbundledPlugin)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batched page modules are transformed in an unbundled Vite environment, so
|
||||
* Rolldown's bundle-graph and output-generation phases never run for them.
|
||||
* Reject user hooks that would otherwise observe or mutate the legacy SSR
|
||||
* page bundle. `buildEnd` is deliberately supported: the unbundled plugin
|
||||
* container runs it during compiler teardown.
|
||||
*
|
||||
* @internal Exported for focused pipeline tests.
|
||||
*/
|
||||
export async function validateSsrBatchPageOutputHooks(
|
||||
plugins: readonly Plugin[],
|
||||
output: Rolldown.OutputOptions | Rolldown.OutputOptions[] | undefined
|
||||
): Promise<void> {
|
||||
const violations: PageUnbundledHookViolation[] = []
|
||||
|
||||
for (const plugin of plugins) {
|
||||
if (isViteInternalPlugin(plugin.name)) continue
|
||||
const hooks = unbundledHooks(plugin)
|
||||
if (hooks.length) {
|
||||
violations.push({ hooks, name: plugin.name, source: 'plugin' })
|
||||
}
|
||||
}
|
||||
|
||||
const outputs = output ? (Array.isArray(output) ? output : [output]) : []
|
||||
for (const [index, options] of outputs.entries()) {
|
||||
const optionHooks = SSR_PAGE_OUTPUT_ADDONS.filter(
|
||||
(hook) => options[hook] != null
|
||||
)
|
||||
if (optionHooks.length) {
|
||||
violations.push({
|
||||
hooks: optionHooks,
|
||||
name: outputs.length === 1 ? 'output' : `output[${index}]`,
|
||||
source: 'output options'
|
||||
})
|
||||
}
|
||||
|
||||
const outputPlugins: PageUnbundledPlugin[] = []
|
||||
await collectOutputPlugins(options.plugins, outputPlugins)
|
||||
for (const plugin of outputPlugins) {
|
||||
const hooks = unbundledHooks(plugin)
|
||||
if (hooks.length) {
|
||||
violations.push({
|
||||
hooks,
|
||||
name: plugin.name || '<anonymous>',
|
||||
source: 'output plugin'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!violations.length) return
|
||||
|
||||
const details = violations
|
||||
.map(
|
||||
({ hooks, name, source }) =>
|
||||
` - ${source} ${JSON.stringify(name)}: ${hooks.join(', ')}`
|
||||
)
|
||||
.join('\n')
|
||||
throw new Error(
|
||||
`SSR batching cannot preserve Rolldown bundle hooks for unbundled SSR page modules:\n${details}\nDisable ssrBuildBatchSize. If these hooks are bundled-only, register the plugin in vite.plugins and exclude it from the unbundled SSR environment with applyToEnvironment.`
|
||||
)
|
||||
}
|
||||
|
||||
export function validateBuildConcurrency(value: unknown): number {
|
||||
if (!Number.isInteger(value) || (value as number) < 1) {
|
||||
throw new Error('buildConcurrency must be a positive integer.')
|
||||
}
|
||||
return value as number
|
||||
}
|
||||
|
||||
export function validateSsrBuildBatchSize(value: unknown): number | undefined {
|
||||
if (value === undefined) return
|
||||
if (!Number.isInteger(value) || (value as number) < 1) {
|
||||
throw new Error('ssrBuildBatchSize must be a positive integer.')
|
||||
}
|
||||
return value as number
|
||||
}
|
||||
|
||||
export function validateSsrBuildWorkerConcurrency(value: unknown): number {
|
||||
if (!Number.isInteger(value) || (value as number) < 1) {
|
||||
throw new Error('ssrBuildWorkerConcurrency must be a positive integer.')
|
||||
}
|
||||
return value as number
|
||||
}
|
||||
|
||||
export function createSsrBatchPlan(
|
||||
sitePages: string[],
|
||||
batchSize: number
|
||||
): SsrBatchPlan[] {
|
||||
batchSize = validateSsrBuildBatchSize(batchSize)!
|
||||
const renderQueue = [
|
||||
'404.md',
|
||||
...sitePages.filter((page) => page !== '404.md')
|
||||
]
|
||||
const batches: SsrBatchPlan[] = []
|
||||
|
||||
for (let offset = 0; offset < renderQueue.length; offset += batchSize) {
|
||||
batches.push({
|
||||
offset,
|
||||
pages: renderQueue.slice(offset, offset + batchSize)
|
||||
})
|
||||
}
|
||||
|
||||
return batches
|
||||
}
|
||||
|
||||
const WORKER_ENTRYPOINT_FLAGS = new Set([
|
||||
'-c',
|
||||
'--check',
|
||||
'-i',
|
||||
'--interactive',
|
||||
'--test',
|
||||
'--watch',
|
||||
'--watch-preserve-output'
|
||||
])
|
||||
|
||||
const WORKER_ENTRYPOINT_FLAGS_WITH_VALUES = new Set([
|
||||
'-e',
|
||||
'--eval',
|
||||
'-p',
|
||||
'--print',
|
||||
'--input-type',
|
||||
'--run',
|
||||
'--watch-path',
|
||||
'--test-concurrency',
|
||||
'--test-isolation',
|
||||
'--test-name-pattern',
|
||||
'--test-reporter',
|
||||
'--test-reporter-destination',
|
||||
'--test-shard',
|
||||
'--test-timeout'
|
||||
])
|
||||
|
||||
export function createWorkerExecArgv(execArgv: string[]): string[] {
|
||||
const result: string[] = []
|
||||
for (let index = 0; index < execArgv.length; index++) {
|
||||
const argument = execArgv[index]
|
||||
|
||||
if (argument === '--') continue
|
||||
|
||||
const shortEntrypointFlags = /^-([ceip]+)$/.exec(argument)?.[1]
|
||||
if (shortEntrypointFlags) {
|
||||
if (/[ep]/.test(shortEntrypointFlags) && index + 1 < execArgv.length) {
|
||||
index++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (WORKER_ENTRYPOINT_FLAGS.has(argument)) continue
|
||||
|
||||
const valueFlag = [...WORKER_ENTRYPOINT_FLAGS_WITH_VALUES].find(
|
||||
(flag) => argument === flag || argument.startsWith(`${flag}=`)
|
||||
)
|
||||
if (valueFlag) {
|
||||
if (argument === valueFlag && index + 1 < execArgv.length) index++
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
argument.startsWith('--test-') ||
|
||||
argument.startsWith('--experimental-test-') ||
|
||||
argument.startsWith('--watch-')
|
||||
) {
|
||||
if (
|
||||
!argument.includes('=') &&
|
||||
index + 1 < execArgv.length &&
|
||||
!execArgv[index + 1].startsWith('-')
|
||||
) {
|
||||
index++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (!argument.startsWith('--inspect')) result.push(argument)
|
||||
|
||||
if (
|
||||
(argument === '--inspect-port' || argument === '--inspect-publish-uid') &&
|
||||
index + 1 < execArgv.length
|
||||
) {
|
||||
index++
|
||||
}
|
||||
}
|
||||
|
||||
// Inspector flags can also arrive through NODE_OPTIONS, which is inherited
|
||||
// by workers. Explicit command-line negatives take precedence there.
|
||||
for (const flag of [
|
||||
'--no-inspect',
|
||||
'--no-inspect-brk',
|
||||
'--no-inspect-wait'
|
||||
]) {
|
||||
if (process.allowedNodeEnvironmentFlags.has(flag)) result.push(flag)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,171 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import type { FetchResult } from 'vite/module-runner'
|
||||
|
||||
export const SSR_MODULE_ARTIFACT_VERSION = 2
|
||||
|
||||
export type MaterializedSsrModuleResult = Exclude<FetchResult, { cache: true }>
|
||||
|
||||
export interface StoredSsrModuleArtifact {
|
||||
version: typeof SSR_MODULE_ARTIFACT_VERSION
|
||||
result: MaterializedSsrModuleResult
|
||||
dependencies: string[]
|
||||
hasUnknownDynamicImports: boolean
|
||||
}
|
||||
|
||||
export interface StoredSsrModuleRequest {
|
||||
version: typeof SSR_MODULE_ARTIFACT_VERSION
|
||||
key: string
|
||||
artifact: string
|
||||
}
|
||||
|
||||
export interface SsrModuleStoreSnapshot {
|
||||
version: typeof SSR_MODULE_ARTIFACT_VERSION
|
||||
requests: [key: string, artifact: string][]
|
||||
}
|
||||
|
||||
export function hashSsrModuleValue(value: string | Buffer): string {
|
||||
return createHash('sha256').update(value).digest('hex')
|
||||
}
|
||||
|
||||
export function ssrModuleCacheFile(root: string, hash: string): string {
|
||||
return path.join(root, hash.slice(0, 2), `${hash.slice(2)}.json`)
|
||||
}
|
||||
|
||||
export function createSsrModuleRequestKey(
|
||||
id: string,
|
||||
importer: string | undefined
|
||||
): string {
|
||||
// Plugin resolveId hooks and final asset resolvers may use the importer even
|
||||
// when `id` is already an absolute path or file URL. Keep that identity in
|
||||
// the request key; equal transformed results still deduplicate in the
|
||||
// content-addressed module store after resolution.
|
||||
return JSON.stringify([SSR_MODULE_ARTIFACT_VERSION, id, importer ?? null])
|
||||
}
|
||||
|
||||
export async function readStoredSsrModuleRequest(
|
||||
storeRoot: string,
|
||||
id: string,
|
||||
importer: string | undefined
|
||||
): Promise<StoredSsrModuleArtifact | undefined> {
|
||||
return readStoredSsrModuleRequestByKey(
|
||||
storeRoot,
|
||||
createSsrModuleRequestKey(id, importer)
|
||||
)
|
||||
}
|
||||
|
||||
export async function readStoredSsrModuleRequestByKey(
|
||||
storeRoot: string,
|
||||
key: string
|
||||
): Promise<StoredSsrModuleArtifact | undefined> {
|
||||
const requestHash = hashSsrModuleValue(key)
|
||||
try {
|
||||
const request = JSON.parse(
|
||||
await readFile(
|
||||
ssrModuleCacheFile(path.join(storeRoot, 'requests'), requestHash),
|
||||
'utf8'
|
||||
)
|
||||
) as StoredSsrModuleRequest
|
||||
if (
|
||||
request.version !== SSR_MODULE_ARTIFACT_VERSION ||
|
||||
request.key !== key
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
return readStoredSsrModuleArtifact(storeRoot, request.artifact)
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof SyntaxError ||
|
||||
(error as NodeJS.ErrnoException).code === 'ENOENT'
|
||||
) {
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function readStoredSsrModuleArtifact(
|
||||
storeRoot: string,
|
||||
artifactHash: string
|
||||
): Promise<StoredSsrModuleArtifact | undefined> {
|
||||
try {
|
||||
const artifact = JSON.parse(
|
||||
await readFile(
|
||||
ssrModuleCacheFile(path.join(storeRoot, 'modules'), artifactHash),
|
||||
'utf8'
|
||||
)
|
||||
) as StoredSsrModuleArtifact
|
||||
if (artifact.version !== SSR_MODULE_ARTIFACT_VERSION) return
|
||||
return artifact
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof SyntaxError ||
|
||||
(error as NodeJS.ErrnoException).code === 'ENOENT'
|
||||
) {
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export class SsrModuleArtifactReader {
|
||||
readonly #snapshot: Promise<Map<string, string> | undefined>
|
||||
readonly #snapshotIsAuthoritative: boolean
|
||||
readonly #artifacts = new Map<
|
||||
string,
|
||||
Promise<StoredSsrModuleArtifact | undefined>
|
||||
>()
|
||||
|
||||
constructor(
|
||||
private readonly storeRoot: string,
|
||||
snapshotPath?: string
|
||||
) {
|
||||
this.#snapshotIsAuthoritative = snapshotPath !== undefined
|
||||
this.#snapshot = this.#readSnapshot(
|
||||
snapshotPath ?? path.join(this.storeRoot, 'snapshot.json')
|
||||
)
|
||||
}
|
||||
|
||||
async read(
|
||||
id: string,
|
||||
importer: string | undefined
|
||||
): Promise<StoredSsrModuleArtifact | undefined> {
|
||||
const key = createSsrModuleRequestKey(id, importer)
|
||||
const artifactHash = (await this.#snapshot)?.get(key)
|
||||
if (!artifactHash) {
|
||||
// A batch snapshot is a complete capability list for that worker. Do
|
||||
// not let pointer files from a broader store silently escape the slice.
|
||||
if (this.#snapshotIsAuthoritative) return
|
||||
return readStoredSsrModuleRequestByKey(this.storeRoot, key)
|
||||
}
|
||||
|
||||
let artifact = this.#artifacts.get(artifactHash)
|
||||
if (!artifact) {
|
||||
artifact = readStoredSsrModuleArtifact(this.storeRoot, artifactHash)
|
||||
this.#artifacts.set(artifactHash, artifact)
|
||||
}
|
||||
return artifact
|
||||
}
|
||||
|
||||
async #readSnapshot(
|
||||
snapshotPath: string
|
||||
): Promise<Map<string, string> | undefined> {
|
||||
try {
|
||||
const snapshot = JSON.parse(
|
||||
await readFile(snapshotPath, 'utf8')
|
||||
) as SsrModuleStoreSnapshot
|
||||
if (snapshot.version !== SSR_MODULE_ARTIFACT_VERSION) return
|
||||
return new Map(snapshot.requests)
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof SyntaxError ||
|
||||
(error as NodeJS.ErrnoException).code === 'ENOENT'
|
||||
) {
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,73 @@
|
||||
import type { ModuleRunnerTransport } from 'vite/module-runner'
|
||||
import { SsrModuleArtifactReader } from './ssrModuleStore'
|
||||
import type { SerializedSsrBuiltin } from './ssrWorkerProtocol'
|
||||
|
||||
interface ViteInvokePayload {
|
||||
type: 'custom'
|
||||
event: 'vite:invoke'
|
||||
data: {
|
||||
name: 'fetchModule' | 'getBuiltins'
|
||||
data: unknown[]
|
||||
}
|
||||
}
|
||||
|
||||
/** Read-only transport for a page graph compiled before workers start. */
|
||||
export class SsrModuleArtifactTransport implements ModuleRunnerTransport {
|
||||
readonly #reader: SsrModuleArtifactReader
|
||||
|
||||
constructor(
|
||||
private readonly moduleStorePath: string,
|
||||
private readonly builtins: SerializedSsrBuiltin[],
|
||||
moduleSnapshotPath?: string
|
||||
) {
|
||||
this.#reader = new SsrModuleArtifactReader(
|
||||
moduleStorePath,
|
||||
moduleSnapshotPath
|
||||
)
|
||||
}
|
||||
|
||||
async invoke(payload: unknown): Promise<{ result: unknown }> {
|
||||
if (!isViteInvokePayload(payload)) {
|
||||
throw new Error('SSR worker received an invalid Vite invoke payload.')
|
||||
}
|
||||
if (payload.data.name === 'getBuiltins') {
|
||||
return { result: this.builtins }
|
||||
}
|
||||
|
||||
const [id, rawImporter, rawOptions] = payload.data.data
|
||||
if (typeof id !== 'string') {
|
||||
throw new TypeError('SSR module artifact request id must be a string.')
|
||||
}
|
||||
const importer = typeof rawImporter === 'string' ? rawImporter : undefined
|
||||
const options =
|
||||
rawOptions && typeof rawOptions === 'object'
|
||||
? (rawOptions as { cached?: boolean })
|
||||
: undefined
|
||||
if (options?.cached) {
|
||||
return { result: { cache: true } }
|
||||
}
|
||||
|
||||
const artifact = await this.#reader.read(id, importer)
|
||||
if (!artifact) {
|
||||
throw new Error(
|
||||
`Missing precompiled SSR module ${JSON.stringify(id)}` +
|
||||
(importer ? ` imported by ${JSON.stringify(importer)}` : '') +
|
||||
`. The coordinator did not materialize the complete page graph at ${this.moduleStorePath}.`
|
||||
)
|
||||
}
|
||||
return { result: artifact.result }
|
||||
}
|
||||
}
|
||||
|
||||
function isViteInvokePayload(value: unknown): value is ViteInvokePayload {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const payload = value as Partial<ViteInvokePayload>
|
||||
const data = payload.data as Partial<ViteInvokePayload['data']> | undefined
|
||||
return (
|
||||
payload.type === 'custom' &&
|
||||
payload.event === 'vite:invoke' &&
|
||||
!!data &&
|
||||
(data.name === 'fetchModule' || data.name === 'getBuiltins') &&
|
||||
Array.isArray(data.data)
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,165 @@
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
import pMap from 'p-map'
|
||||
import { createNodeImportMeta, ModuleRunner } from 'vite/module-runner'
|
||||
import { notFoundPageData, type PageData, type SSGContext } from '../shared'
|
||||
import { nativeImport } from '../utils/nativeImport'
|
||||
import type { SerializedRenderedPage, SerializedSSGContext } from './render'
|
||||
import {
|
||||
serializeSsrRenderWorkerResult,
|
||||
type SsrRenderWorkerDescriptor,
|
||||
type SsrRenderWorkerResult
|
||||
} from './ssrWorkerProtocol'
|
||||
import { SsrModuleArtifactTransport } from './ssrModuleTransport'
|
||||
|
||||
interface RuntimeModule {
|
||||
renderPage(path: string, pageModule: unknown): Promise<SSGContext>
|
||||
}
|
||||
|
||||
interface LoadedPageModule {
|
||||
default?: unknown
|
||||
__pageData?: PageData
|
||||
}
|
||||
|
||||
function serializeContext(context: SSGContext): SerializedSSGContext {
|
||||
const serialized = {
|
||||
...context,
|
||||
vpSocialIcons: [...context.vpSocialIcons].sort()
|
||||
} as SerializedSSGContext & {
|
||||
__teleportBuffers?: unknown
|
||||
__watcherHandles?: unknown
|
||||
}
|
||||
|
||||
// Vue leaves these private render-time structures on SSRContext after it
|
||||
// has produced the public `teleports` output. Watcher handles contain
|
||||
// functions and therefore cannot cross the V8 serialization boundary; the
|
||||
// teleport buffers are redundant once renderToString has resolved them.
|
||||
delete serialized.__teleportBuffers
|
||||
delete serialized.__watcherHandles
|
||||
return serialized
|
||||
}
|
||||
|
||||
function validatePageModule(
|
||||
pageModule: LoadedPageModule,
|
||||
moduleId: string
|
||||
): asserts pageModule is LoadedPageModule & {
|
||||
default: NonNullable<unknown>
|
||||
__pageData: PageData
|
||||
} {
|
||||
if (!pageModule.default) {
|
||||
throw new Error(
|
||||
`SSR page module ${moduleId} has no default component export.`
|
||||
)
|
||||
}
|
||||
if (!pageModule.__pageData) {
|
||||
throw new Error(`SSR page module ${moduleId} did not export __pageData.`)
|
||||
}
|
||||
}
|
||||
|
||||
async function renderBatch(
|
||||
descriptor: SsrRenderWorkerDescriptor
|
||||
): Promise<SsrRenderWorkerResult> {
|
||||
const runtime = (await nativeImport(descriptor.runtimePath)) as RuntimeModule
|
||||
if (typeof runtime.renderPage !== 'function') {
|
||||
throw new Error(
|
||||
`Shared SSR runtime at ${descriptor.runtimePath} does not export renderPage().`
|
||||
)
|
||||
}
|
||||
|
||||
const needsModuleRunner = descriptor.pages.some(
|
||||
(page) => page.moduleId && !page.staticPage
|
||||
)
|
||||
const runner = needsModuleRunner
|
||||
? new ModuleRunner({
|
||||
transport: new SsrModuleArtifactTransport(
|
||||
descriptor.moduleStorePath,
|
||||
descriptor.builtins,
|
||||
descriptor.moduleSnapshotPath
|
||||
),
|
||||
hmr: false,
|
||||
createImportMeta: createNodeImportMeta,
|
||||
sourcemapInterceptor: false
|
||||
})
|
||||
: undefined
|
||||
try {
|
||||
const pages = await pMap(
|
||||
descriptor.pages,
|
||||
async (page): Promise<SerializedRenderedPage> => {
|
||||
try {
|
||||
const pageModule = page.staticPage
|
||||
? null
|
||||
: page.moduleId
|
||||
? ((await runner!.import(page.moduleId)) as LoadedPageModule)
|
||||
: null
|
||||
if (pageModule && page.moduleId) {
|
||||
validatePageModule(pageModule, page.moduleId)
|
||||
}
|
||||
|
||||
const payload = page.staticPage ?? pageModule
|
||||
const context = await runtime.renderPage(page.routePath, payload)
|
||||
const pageData =
|
||||
page.staticPage?.pageData ??
|
||||
pageModule?.__pageData ??
|
||||
(page.page === '404.md' ? notFoundPageData : undefined)
|
||||
|
||||
if (!pageData) {
|
||||
throw new Error(
|
||||
`SSR page ${page.page} has neither a static payload nor a page-data module.`
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
page: page.page,
|
||||
pageData,
|
||||
hasCustom404: page.page !== '404.md' || payload !== null,
|
||||
context: serializeContext(context)
|
||||
}
|
||||
} catch (error) {
|
||||
const detail =
|
||||
error instanceof Error
|
||||
? (error.stack ?? error.message)
|
||||
: String(error)
|
||||
throw new Error(
|
||||
`Failed to render ${page.page} in SSR worker: ${detail}`,
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
concurrency: descriptor.renderConcurrency,
|
||||
// Do not close the shared ModuleRunner while another mapper is still
|
||||
// importing or rendering. p-map reports an AggregateError after the
|
||||
// bounded batch has settled when any page fails.
|
||||
stopOnError: false
|
||||
}
|
||||
)
|
||||
return { pages }
|
||||
} finally {
|
||||
await runner?.close()
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const descriptorPath = process.argv[2]
|
||||
if (!descriptorPath) {
|
||||
throw new Error('Missing SSR worker descriptor path.')
|
||||
}
|
||||
|
||||
const descriptor = JSON.parse(
|
||||
await readFile(descriptorPath, 'utf8')
|
||||
) as SsrRenderWorkerDescriptor
|
||||
if (descriptor.type !== 'ssr-render') {
|
||||
throw new Error('Unknown SSR worker descriptor type.')
|
||||
}
|
||||
|
||||
const result = await renderBatch(descriptor)
|
||||
await writeFile(
|
||||
descriptor.resultPath,
|
||||
serializeSsrRenderWorkerResult(result),
|
||||
{ mode: 0o600 }
|
||||
)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error : new Error(String(error)))
|
||||
process.exitCode = 1
|
||||
})
|
||||
@ -0,0 +1,54 @@
|
||||
import { serialize } from 'node:v8'
|
||||
import type { PageData } from '../shared'
|
||||
import type { SerializedRenderedPage } from './render'
|
||||
|
||||
export type SerializedSsrBuiltin =
|
||||
| { type: 'string'; value: string }
|
||||
| { type: 'RegExp'; source: string; flags: string }
|
||||
|
||||
export interface SsrStaticPagePayload {
|
||||
html: string
|
||||
pageData: PageData
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything a disposable worker needs for one route. `page` is the rewritten
|
||||
* output page while `moduleId` points at the original source module.
|
||||
*/
|
||||
export interface SsrRenderWorkerPage {
|
||||
page: string
|
||||
routePath: string
|
||||
moduleId: string | null
|
||||
staticPage?: SsrStaticPagePayload
|
||||
}
|
||||
|
||||
/** JSON descriptor written by the build coordinator. */
|
||||
export interface SsrRenderWorkerDescriptor {
|
||||
type: 'ssr-render'
|
||||
runtimePath: string
|
||||
moduleStorePath: string
|
||||
/** Request-key slice containing only modules reachable by this batch. */
|
||||
moduleSnapshotPath: string
|
||||
builtins: SerializedSsrBuiltin[]
|
||||
resultPath: string
|
||||
renderConcurrency: number
|
||||
pages: SsrRenderWorkerPage[]
|
||||
}
|
||||
|
||||
/** V8-serialized worker output. */
|
||||
export interface SsrRenderWorkerResult {
|
||||
pages: SerializedRenderedPage[]
|
||||
}
|
||||
|
||||
export function serializeSsrRenderWorkerResult(
|
||||
result: SsrRenderWorkerResult
|
||||
): Buffer {
|
||||
try {
|
||||
return serialize(result)
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
'Unable to transfer the SSR render result to the build coordinator. Custom values added to SSGContext must be structured-cloneable when ssrBuildBatchSize is enabled; functions, symbols, WeakMaps, and proxies cannot cross the render-worker boundary.',
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,508 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { deserialize, serialize } from 'node:v8'
|
||||
import type { MarkdownCompileResult } from './markdownToVue'
|
||||
import { slash } from './shared'
|
||||
|
||||
const PAGE_ARTIFACT_SCHEMA_VERSION = 8
|
||||
|
||||
export interface PageArtifactManifestEntry {
|
||||
inputHash: string
|
||||
objectHash: string
|
||||
dependencies: { file: string; hash: string }[]
|
||||
metadata: PageArtifactMetadata
|
||||
}
|
||||
|
||||
export interface PageArtifactMetadata {
|
||||
staticPage: boolean
|
||||
requiresSourceModuleIdentity: boolean
|
||||
}
|
||||
|
||||
interface PageArtifactManifest {
|
||||
schemaVersion: number
|
||||
namespace: string
|
||||
entries: Record<string, PageArtifactManifestEntry>
|
||||
}
|
||||
|
||||
type StoredArtifactOverlay = Omit<
|
||||
MarkdownCompileResult,
|
||||
'html' | 'staticHtml' | 'vueSrc'
|
||||
>
|
||||
|
||||
type StoredVueSource = { source: string } | { prefix: string; suffix: string }
|
||||
|
||||
interface StoredPageArtifact {
|
||||
schemaVersion: number
|
||||
artifact: StoredArtifactOverlay
|
||||
htmlHash: string
|
||||
staticHtmlHash?: string
|
||||
vueSource: StoredVueSource
|
||||
}
|
||||
|
||||
export type PageArtifactFinalizer = (
|
||||
artifact: MarkdownCompileResult
|
||||
) => Promise<MarkdownCompileResult>
|
||||
|
||||
interface CurrentPageArtifact {
|
||||
inputHash: string
|
||||
objectHash: string
|
||||
finalized: boolean
|
||||
}
|
||||
|
||||
export interface PageArtifactStoreOptions {
|
||||
/**
|
||||
* A fingerprint for everything that can affect Markdown output. The build
|
||||
* coordinator should include the VitePress version, resolved route digest,
|
||||
* config dependencies, base, Markdown options and relevant environment data.
|
||||
*/
|
||||
namespace: string
|
||||
/** Prevent creation of missing artifacts. Useful in render-only workers. */
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A small content-addressed store for the expensive Markdown -> Vue boundary.
|
||||
*
|
||||
* The manifest only retains hashes and dependency paths. Rendered HTML bodies
|
||||
* and route-specific overlays are written immediately to separate CAS objects,
|
||||
* so compiling a large site does not retain every page in memory and exactly
|
||||
* identical bodies can share storage even when their overlays differ.
|
||||
*/
|
||||
export class PageArtifactStore {
|
||||
readonly root: string
|
||||
readonly namespace: string
|
||||
readonly readOnly: boolean
|
||||
|
||||
readonly #manifestPath: string
|
||||
readonly #objectsDir: string
|
||||
readonly #bodiesDir: string
|
||||
readonly #entries = new Map<string, PageArtifactManifestEntry>()
|
||||
// Only CAS coordinates are retained. Finalized Vue/HTML strings are read
|
||||
// back on demand so a streaming build never accumulates every page in RAM.
|
||||
readonly #current = new Map<string, CurrentPageArtifact>()
|
||||
readonly #pending = new Map<string, Promise<MarkdownCompileResult>>()
|
||||
readonly #dependencyHashes = new Map<string, Promise<string>>()
|
||||
#loaded: Promise<void> | undefined
|
||||
#dirty = false
|
||||
|
||||
constructor(root: string, options: PageArtifactStoreOptions) {
|
||||
this.namespace = options.namespace
|
||||
this.readOnly = options.readOnly ?? false
|
||||
|
||||
const namespaceHash = hash(options.namespace).slice(0, 20)
|
||||
this.root = path.resolve(root, 'vitepress-page-artifacts')
|
||||
this.#manifestPath = path.join(
|
||||
this.root,
|
||||
'manifests',
|
||||
`${namespaceHash}.json`
|
||||
)
|
||||
this.#objectsDir = path.join(this.root, 'objects')
|
||||
this.#bodiesDir = path.join(this.root, 'bodies')
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads an artifact only when both its transformed Markdown input and every
|
||||
* include/snippet dependency still match the manifest.
|
||||
*/
|
||||
async get(
|
||||
page: string,
|
||||
transformedSource: string
|
||||
): Promise<MarkdownCompileResult | undefined> {
|
||||
await this.#load()
|
||||
page = normalizePageKey(page)
|
||||
|
||||
const inputHash = this.createInputHash(page, transformedSource)
|
||||
const stored = await this.#getValidatedStored(page, inputHash)
|
||||
if (!stored) return
|
||||
|
||||
this.#current.set(page, {
|
||||
inputHash,
|
||||
objectHash: stored.objectHash,
|
||||
finalized: false
|
||||
})
|
||||
return stored.artifact
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an artifact already validated or created during this build. This is
|
||||
* intended for coordinator-owned consumers such as local search generation.
|
||||
*/
|
||||
async getCurrent(page: string): Promise<MarkdownCompileResult | undefined> {
|
||||
await this.#load()
|
||||
page = normalizePageKey(page)
|
||||
const current = this.#current.get(page)
|
||||
return current ? this.#readObject(current.objectHash) : undefined
|
||||
}
|
||||
|
||||
/** Read small routing metadata without parsing the full artifact object. */
|
||||
async getCurrentMetadata(
|
||||
page: string
|
||||
): Promise<PageArtifactMetadata | undefined> {
|
||||
await this.#load()
|
||||
page = normalizePageKey(page)
|
||||
if (!this.#current.has(page)) return
|
||||
const metadata = this.#entries.get(page)?.metadata
|
||||
return metadata ? { ...metadata } : undefined
|
||||
}
|
||||
|
||||
async put(
|
||||
page: string,
|
||||
transformedSource: string,
|
||||
artifact: MarkdownCompileResult
|
||||
): Promise<void> {
|
||||
if (this.readOnly) {
|
||||
throw new Error(
|
||||
`Cannot create page artifact for ${page} in read-only mode.`
|
||||
)
|
||||
}
|
||||
await this.#load()
|
||||
page = normalizePageKey(page)
|
||||
|
||||
await this.#putBase(page, transformedSource, artifact, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicates concurrent requests for the same page/input. A read-only
|
||||
* consumer can omit `compile` to turn a cache miss into an actionable error.
|
||||
*/
|
||||
async getOrCreate(
|
||||
page: string,
|
||||
transformedSource: string,
|
||||
compile?: () => Promise<MarkdownCompileResult>,
|
||||
finalize?: PageArtifactFinalizer
|
||||
): Promise<MarkdownCompileResult> {
|
||||
page = normalizePageKey(page)
|
||||
const operationKey = this.createInputHash(page, transformedSource)
|
||||
const pending = this.#pending.get(operationKey)
|
||||
if (pending) return pending
|
||||
|
||||
const operation = (async () => {
|
||||
await this.#load()
|
||||
const current = this.#current.get(page)
|
||||
if (
|
||||
current?.inputHash === operationKey &&
|
||||
(!finalize || current.finalized)
|
||||
) {
|
||||
const currentArtifact = await this.#readObject(current.objectHash)
|
||||
if (currentArtifact) return currentArtifact
|
||||
}
|
||||
|
||||
let baseStored = await this.#getValidatedStored(page, operationKey)
|
||||
if (!baseStored) {
|
||||
if (!compile) {
|
||||
throw new Error(
|
||||
`Missing or stale Markdown artifact for ${page}. ` +
|
||||
'The coordinator must compile page artifacts before starting render workers.'
|
||||
)
|
||||
}
|
||||
|
||||
const artifact = await compile()
|
||||
const objectHash = await this.#putBase(
|
||||
page,
|
||||
transformedSource,
|
||||
artifact,
|
||||
false
|
||||
)
|
||||
baseStored = { objectHash, artifact }
|
||||
}
|
||||
|
||||
const artifact = finalize
|
||||
? await finalize(baseStored.artifact)
|
||||
: baseStored.artifact
|
||||
let objectHash = baseStored.objectHash
|
||||
if (finalize) {
|
||||
if (artifact === baseStored.artifact) {
|
||||
objectHash = baseStored.objectHash
|
||||
} else if (this.readOnly) {
|
||||
objectHash = this.#hashStoredArtifact(artifact)
|
||||
if (!(await this.#readStoredObject(objectHash))) {
|
||||
throw new Error(
|
||||
`Missing finalized Markdown artifact for ${page}. ` +
|
||||
'The coordinator must finalize page data before starting read-only consumers.'
|
||||
)
|
||||
}
|
||||
} else {
|
||||
objectHash = await this.#writeStoredObject(artifact)
|
||||
}
|
||||
}
|
||||
this.#current.set(page, {
|
||||
inputHash: operationKey,
|
||||
objectHash,
|
||||
finalized: !!finalize
|
||||
})
|
||||
return artifact
|
||||
})()
|
||||
|
||||
this.#pending.set(operationKey, operation)
|
||||
try {
|
||||
return await operation
|
||||
} finally {
|
||||
this.#pending.delete(operationKey)
|
||||
}
|
||||
}
|
||||
|
||||
createInputHash(page: string, transformedSource: string): string {
|
||||
return hashParts([
|
||||
`vitepress-page-artifact-v${PAGE_ARTIFACT_SCHEMA_VERSION}`,
|
||||
this.namespace,
|
||||
normalizePageKey(page),
|
||||
transformedSource
|
||||
])
|
||||
}
|
||||
|
||||
/** Flushes the small sorted manifest after a streaming compilation pass. */
|
||||
async flush(): Promise<void> {
|
||||
await this.#load()
|
||||
if (this.readOnly || !this.#dirty) return
|
||||
|
||||
const entries = Object.fromEntries(
|
||||
[...this.#entries].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
||||
)
|
||||
const manifest: PageArtifactManifest = {
|
||||
schemaVersion: PAGE_ARTIFACT_SCHEMA_VERSION,
|
||||
namespace: this.namespace,
|
||||
entries
|
||||
}
|
||||
|
||||
await mkdir(path.dirname(this.#manifestPath), { recursive: true })
|
||||
await atomicWrite(this.#manifestPath, JSON.stringify(manifest))
|
||||
this.#dirty = false
|
||||
}
|
||||
|
||||
async #load(): Promise<void> {
|
||||
if (this.#loaded) return this.#loaded
|
||||
|
||||
this.#loaded = (async () => {
|
||||
try {
|
||||
const manifest = JSON.parse(
|
||||
await readFile(this.#manifestPath, 'utf8')
|
||||
) as PageArtifactManifest
|
||||
if (
|
||||
manifest.schemaVersion !== PAGE_ARTIFACT_SCHEMA_VERSION ||
|
||||
manifest.namespace !== this.namespace
|
||||
) {
|
||||
return
|
||||
}
|
||||
for (const [page, entry] of Object.entries(manifest.entries)) {
|
||||
this.#entries.set(page, entry)
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
// A partial/corrupt cache must never make a build fail. Treat it as a
|
||||
// miss; a successful build will atomically replace the manifest.
|
||||
return
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
return this.#loaded
|
||||
}
|
||||
|
||||
async #readObject(
|
||||
objectHash: string
|
||||
): Promise<MarkdownCompileResult | undefined> {
|
||||
return this.#readStoredObject(objectHash)
|
||||
}
|
||||
|
||||
async #readStoredObject(
|
||||
objectHash: string
|
||||
): Promise<MarkdownCompileResult | undefined> {
|
||||
try {
|
||||
const stored = deserialize(
|
||||
await readFile(this.#objectPath(objectHash))
|
||||
) as StoredPageArtifact
|
||||
if (stored.schemaVersion !== PAGE_ARTIFACT_SCHEMA_VERSION) return
|
||||
const [html, staticHtml] = await Promise.all([
|
||||
readFile(this.#bodyPath(stored.htmlHash), 'utf8'),
|
||||
stored.staticHtmlHash
|
||||
? readFile(this.#bodyPath(stored.staticHtmlHash), 'utf8')
|
||||
: undefined
|
||||
])
|
||||
const vueSrc =
|
||||
'source' in stored.vueSource
|
||||
? stored.vueSource.source
|
||||
: `${stored.vueSource.prefix}${html}${stored.vueSource.suffix}`
|
||||
return {
|
||||
...stored.artifact,
|
||||
html,
|
||||
vueSrc,
|
||||
...(staticHtml === undefined ? {} : { staticHtml })
|
||||
}
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
async #getValidatedStored(
|
||||
page: string,
|
||||
inputHash: string
|
||||
): Promise<
|
||||
{ objectHash: string; artifact: MarkdownCompileResult } | undefined
|
||||
> {
|
||||
const entry = this.#entries.get(page)
|
||||
if (!entry || entry.inputHash !== inputHash) return
|
||||
if (!(await this.#dependenciesMatch(entry.dependencies))) return
|
||||
|
||||
const artifact = await this.#readStoredObject(entry.objectHash)
|
||||
return artifact ? { objectHash: entry.objectHash, artifact } : undefined
|
||||
}
|
||||
|
||||
async #putBase(
|
||||
page: string,
|
||||
transformedSource: string,
|
||||
artifact: MarkdownCompileResult,
|
||||
markCurrent: boolean
|
||||
): Promise<string> {
|
||||
const objectHash = await this.#writeStoredObject(artifact)
|
||||
const inputHash = this.createInputHash(page, transformedSource)
|
||||
const dependencies = await this.#hashDependencies(artifact.includes)
|
||||
this.#entries.set(page, {
|
||||
inputHash,
|
||||
objectHash,
|
||||
dependencies,
|
||||
metadata: {
|
||||
staticPage: artifact.staticPage === true,
|
||||
requiresSourceModuleIdentity:
|
||||
artifact.requiresSourceModuleIdentity === true
|
||||
}
|
||||
})
|
||||
if (markCurrent) {
|
||||
this.#current.set(page, {
|
||||
inputHash,
|
||||
objectHash,
|
||||
finalized: false
|
||||
})
|
||||
}
|
||||
this.#dirty = true
|
||||
return objectHash
|
||||
}
|
||||
|
||||
async #writeStoredObject(artifact: MarkdownCompileResult): Promise<string> {
|
||||
const stored = this.#createStoredArtifact(artifact)
|
||||
await Promise.all([
|
||||
this.#writeBody(stored.htmlHash, artifact.html),
|
||||
stored.staticHtmlHash && artifact.staticHtml !== undefined
|
||||
? this.#writeBody(stored.staticHtmlHash, artifact.staticHtml)
|
||||
: undefined
|
||||
])
|
||||
const source = serialize(stored)
|
||||
const objectHash = hash(source)
|
||||
const objectPath = this.#objectPath(objectHash)
|
||||
await mkdir(path.dirname(objectPath), { recursive: true })
|
||||
await atomicWriteIfChanged(objectPath, source)
|
||||
return objectHash
|
||||
}
|
||||
|
||||
#hashStoredArtifact(artifact: MarkdownCompileResult): string {
|
||||
return hash(serialize(this.#createStoredArtifact(artifact)))
|
||||
}
|
||||
|
||||
#createStoredArtifact(artifact: MarkdownCompileResult): StoredPageArtifact {
|
||||
const { html, staticHtml, vueSrc, ...overlay } = artifact
|
||||
return {
|
||||
schemaVersion: PAGE_ARTIFACT_SCHEMA_VERSION,
|
||||
artifact: overlay,
|
||||
htmlHash: hash(html),
|
||||
...(staticHtml === undefined ? {} : { staticHtmlHash: hash(staticHtml) }),
|
||||
vueSource: compactVueSource(vueSrc, html)
|
||||
}
|
||||
}
|
||||
|
||||
async #writeBody(bodyHash: string, body: string): Promise<void> {
|
||||
const bodyPath = this.#bodyPath(bodyHash)
|
||||
await mkdir(path.dirname(bodyPath), { recursive: true })
|
||||
await atomicWriteIfChanged(bodyPath, Buffer.from(body))
|
||||
}
|
||||
|
||||
#objectPath(objectHash: string): string {
|
||||
return path.join(
|
||||
this.#objectsDir,
|
||||
objectHash.slice(0, 2),
|
||||
`${objectHash}.bin`
|
||||
)
|
||||
}
|
||||
|
||||
#bodyPath(bodyHash: string): string {
|
||||
return path.join(this.#bodiesDir, bodyHash.slice(0, 2), `${bodyHash}.html`)
|
||||
}
|
||||
|
||||
async #hashDependencies(
|
||||
files: string[]
|
||||
): Promise<PageArtifactManifestEntry['dependencies']> {
|
||||
const uniqueFiles = [...new Set(files.map((file) => slash(file)))].sort()
|
||||
return Promise.all(
|
||||
uniqueFiles.map(async (file) => ({
|
||||
file,
|
||||
hash: await this.#hashDependency(file)
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
async #dependenciesMatch(
|
||||
dependencies: PageArtifactManifestEntry['dependencies']
|
||||
): Promise<boolean> {
|
||||
const matches = await Promise.all(
|
||||
dependencies.map(
|
||||
async ({ file, hash: expected }) =>
|
||||
(await this.#hashDependency(file)) === expected
|
||||
)
|
||||
)
|
||||
return matches.every(Boolean)
|
||||
}
|
||||
|
||||
#hashDependency(file: string): Promise<string> {
|
||||
let pending = this.#dependencyHashes.get(file)
|
||||
if (!pending) {
|
||||
pending = readFile(file).then(hash, () => '<missing>')
|
||||
this.#dependencyHashes.set(file, pending)
|
||||
}
|
||||
return pending
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePageKey(page: string): string {
|
||||
return slash(page).replace(/^\.\//, '')
|
||||
}
|
||||
|
||||
function compactVueSource(vueSrc: string, html: string): StoredVueSource {
|
||||
const template = `<template><div>${html}</div></template>`
|
||||
const index = vueSrc.indexOf(template)
|
||||
if (index < 0) return { source: vueSrc }
|
||||
|
||||
const bodyOffset = index + '<template><div>'.length
|
||||
return {
|
||||
prefix: vueSrc.slice(0, bodyOffset),
|
||||
suffix: vueSrc.slice(bodyOffset + html.length)
|
||||
}
|
||||
}
|
||||
|
||||
function hash(value: string | Uint8Array): string {
|
||||
return createHash('sha256').update(value).digest('hex')
|
||||
}
|
||||
|
||||
function hashParts(parts: string[]): string {
|
||||
const digest = createHash('sha256')
|
||||
for (const part of parts) {
|
||||
digest.update(`${Buffer.byteLength(part)}:`)
|
||||
digest.update(part)
|
||||
}
|
||||
return digest.digest('hex')
|
||||
}
|
||||
|
||||
async function atomicWriteIfChanged(file: string, content: Uint8Array) {
|
||||
try {
|
||||
if (Buffer.compare(await readFile(file), content) === 0) return
|
||||
} catch {}
|
||||
await atomicWrite(file, content)
|
||||
}
|
||||
|
||||
async function atomicWrite(file: string, content: string | Uint8Array) {
|
||||
const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`
|
||||
await writeFile(temporary, content, { mode: 0o600 })
|
||||
try {
|
||||
await rename(temporary, file)
|
||||
} finally {
|
||||
await unlink(temporary).catch(() => {})
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,265 @@
|
||||
import { normalizePath, type Plugin, type ResolvedConfig } from 'vite'
|
||||
|
||||
export const VUE_DESCRIPTOR_MEMORY_PLUGIN = 'vitepress:vue-descriptor-memory'
|
||||
|
||||
interface MutableSfcBlock {
|
||||
content?: string
|
||||
map?: unknown
|
||||
ast?: unknown
|
||||
loc?: { source?: string }
|
||||
}
|
||||
|
||||
interface MutableSfcDescriptor {
|
||||
filename?: string
|
||||
source?: string
|
||||
template?: MutableSfcBlock | null
|
||||
script?: MutableSfcBlock | null
|
||||
scriptSetup?: MutableSfcBlock | null
|
||||
styles?: MutableSfcBlock[]
|
||||
customBlocks?: MutableSfcBlock[]
|
||||
}
|
||||
|
||||
interface MutableCompileScriptResult {
|
||||
content?: string
|
||||
map?: unknown
|
||||
scriptAst?: unknown
|
||||
scriptSetupAst?: unknown
|
||||
deps?: string[]
|
||||
imports?: Record<string, unknown>
|
||||
bindings?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface MutableVueCompiler {
|
||||
parse: (...args: any[]) => { descriptor?: MutableSfcDescriptor }
|
||||
compileScript?: (
|
||||
descriptor: MutableSfcDescriptor,
|
||||
...args: any[]
|
||||
) => MutableCompileScriptResult
|
||||
parseCache?: { clear?: () => void }
|
||||
}
|
||||
|
||||
interface MutableVuePluginOptions {
|
||||
compiler?: MutableVueCompiler
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface MutableVuePluginApi {
|
||||
options: MutableVuePluginOptions
|
||||
}
|
||||
|
||||
interface VuePluginWithCompiler extends Plugin {
|
||||
api?: MutableVuePluginApi
|
||||
}
|
||||
|
||||
export interface VueDescriptorMemoryApi {
|
||||
release(files?: Iterable<string>): void
|
||||
readonly retainedFiles: number
|
||||
}
|
||||
|
||||
const descriptorLeases = new WeakMap<MutableSfcDescriptor, number>()
|
||||
const scriptLeases = new WeakMap<MutableCompileScriptResult, number>()
|
||||
|
||||
function retainTracked<T extends object>(
|
||||
values: Set<T>,
|
||||
value: T,
|
||||
leases: WeakMap<T, number>
|
||||
): void {
|
||||
if (values.has(value)) return
|
||||
values.add(value)
|
||||
leases.set(value, (leases.get(value) ?? 0) + 1)
|
||||
}
|
||||
|
||||
function releaseTracked<T extends object>(
|
||||
values: Set<T> | undefined,
|
||||
leases: WeakMap<T, number>,
|
||||
compact: (value: T) => void
|
||||
): void {
|
||||
if (!values) return
|
||||
for (const value of values) {
|
||||
const count = leases.get(value) ?? 1
|
||||
if (count <= 1) {
|
||||
leases.delete(value)
|
||||
compact(value)
|
||||
} else {
|
||||
leases.set(value, count - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function compactBlock(block: MutableSfcBlock | null | undefined): void {
|
||||
if (!block) return
|
||||
block.content = ''
|
||||
block.map = undefined
|
||||
block.ast = undefined
|
||||
if (block.loc) block.loc.source = ''
|
||||
}
|
||||
|
||||
function compactDescriptor(descriptor: MutableSfcDescriptor): void {
|
||||
descriptor.source = ''
|
||||
compactBlock(descriptor.template)
|
||||
compactBlock(descriptor.script)
|
||||
compactBlock(descriptor.scriptSetup)
|
||||
descriptor.styles?.forEach(compactBlock)
|
||||
descriptor.customBlocks?.forEach(compactBlock)
|
||||
}
|
||||
|
||||
function compactScript(result: MutableCompileScriptResult): void {
|
||||
result.content = ''
|
||||
result.map = undefined
|
||||
result.scriptAst = undefined
|
||||
result.scriptSetupAst = undefined
|
||||
result.deps = []
|
||||
result.imports = {}
|
||||
result.bindings = {}
|
||||
}
|
||||
|
||||
/**
|
||||
* @vitejs/plugin-vue keeps production SFC descriptors in process-global maps.
|
||||
* On documentation sites this otherwise retains every Markdown page's full
|
||||
* source, block locations and template AST after its compiled module has been
|
||||
* emitted. Track the same descriptor objects through the public compiler
|
||||
* option and compact them only after the owning build/batch has completed.
|
||||
*/
|
||||
export function createVueDescriptorMemoryPlugin(
|
||||
vuePlugin: Plugin
|
||||
): Plugin & { api: VueDescriptorMemoryApi } {
|
||||
const descriptors = new Map<string, Set<MutableSfcDescriptor>>()
|
||||
const scripts = new Map<string, Set<MutableCompileScriptResult>>()
|
||||
let vueApi: MutableVuePluginApi | undefined
|
||||
let sourceCompiler: MutableVueCompiler | undefined
|
||||
let compilerFacade: MutableVueCompiler | undefined
|
||||
let originalParse: MutableVueCompiler['parse'] | undefined
|
||||
let originalCompileScript: MutableVueCompiler['compileScript'] | undefined
|
||||
|
||||
const release: VueDescriptorMemoryApi['release'] = (files) => {
|
||||
const selected = files
|
||||
? new Set([...files].map((file) => normalizePath(file)))
|
||||
: new Set([...descriptors.keys(), ...scripts.keys()])
|
||||
|
||||
for (const file of selected) {
|
||||
releaseTracked(descriptors.get(file), descriptorLeases, compactDescriptor)
|
||||
releaseTracked(scripts.get(file), scriptLeases, compactScript)
|
||||
descriptors.delete(file)
|
||||
scripts.delete(file)
|
||||
}
|
||||
sourceCompiler?.parseCache?.clear?.()
|
||||
}
|
||||
|
||||
const dispose = () => {
|
||||
release()
|
||||
if (
|
||||
vueApi &&
|
||||
sourceCompiler &&
|
||||
compilerFacade &&
|
||||
vueApi.options.compiler === compilerFacade
|
||||
) {
|
||||
vueApi.options = {
|
||||
...vueApi.options,
|
||||
compiler: sourceCompiler
|
||||
}
|
||||
}
|
||||
vueApi = undefined
|
||||
sourceCompiler = undefined
|
||||
compilerFacade = undefined
|
||||
originalParse = undefined
|
||||
originalCompileScript = undefined
|
||||
}
|
||||
|
||||
const api: VueDescriptorMemoryApi = {
|
||||
release,
|
||||
get retainedFiles() {
|
||||
return new Set([...descriptors.keys(), ...scripts.keys()]).size
|
||||
}
|
||||
}
|
||||
|
||||
const memoryPlugin: Plugin & { api: VueDescriptorMemoryApi } = {
|
||||
name: VUE_DESCRIPTOR_MEMORY_PLUGIN,
|
||||
api,
|
||||
buildStart: {
|
||||
order: 'post',
|
||||
sequential: true,
|
||||
handler() {
|
||||
const api = (vuePlugin as VuePluginWithCompiler).api
|
||||
const resolvedCompiler = api?.options.compiler
|
||||
if (!api || !resolvedCompiler || resolvedCompiler === compilerFacade) {
|
||||
return
|
||||
}
|
||||
|
||||
dispose()
|
||||
vueApi = api
|
||||
sourceCompiler = resolvedCompiler
|
||||
originalParse = resolvedCompiler.parse
|
||||
|
||||
// plugin-vue resolves @vue/compiler-sfc to a process-global module
|
||||
// namespace. Never patch that namespace directly: concurrent Vite
|
||||
// builds can use it at the same time. Give only this plugin instance
|
||||
// an isolated facade and replace it through plugin-vue's public API.
|
||||
const facade = Object.create(resolvedCompiler) as MutableVueCompiler
|
||||
facade.parse = function (...args) {
|
||||
const result = originalParse!.apply(sourceCompiler, args)
|
||||
const descriptor = result?.descriptor
|
||||
if (descriptor?.filename) {
|
||||
const filename = normalizePath(descriptor.filename)
|
||||
let retained = descriptors.get(filename)
|
||||
if (!retained) descriptors.set(filename, (retained = new Set()))
|
||||
retainTracked(retained, descriptor, descriptorLeases)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
if (resolvedCompiler.compileScript) {
|
||||
originalCompileScript = resolvedCompiler.compileScript
|
||||
facade.compileScript = function (descriptor, ...args) {
|
||||
const result = originalCompileScript!.call(
|
||||
sourceCompiler,
|
||||
descriptor,
|
||||
...args
|
||||
)
|
||||
if (descriptor.filename) {
|
||||
const filename = normalizePath(descriptor.filename)
|
||||
let retained = scripts.get(filename)
|
||||
if (!retained) scripts.set(filename, (retained = new Set()))
|
||||
retainTracked(retained, result, scriptLeases)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
compilerFacade = facade
|
||||
api.options = {
|
||||
...api.options,
|
||||
compiler: facade
|
||||
}
|
||||
}
|
||||
},
|
||||
buildEnd: {
|
||||
order: 'post',
|
||||
sequential: true,
|
||||
handler(error) {
|
||||
release()
|
||||
// Rollup does not guarantee closeBundle after graph-construction
|
||||
// failure, so restore this plugin instance's facade here on errors.
|
||||
if (error) dispose()
|
||||
}
|
||||
},
|
||||
closeBundle: {
|
||||
order: 'post',
|
||||
sequential: true,
|
||||
handler() {
|
||||
dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return memoryPlugin
|
||||
}
|
||||
|
||||
export function getVueDescriptorMemoryApi(
|
||||
config: ResolvedConfig
|
||||
): VueDescriptorMemoryApi | undefined {
|
||||
return (
|
||||
config.plugins.find(
|
||||
(plugin) => plugin.name === VUE_DESCRIPTOR_MEMORY_PLUGIN
|
||||
) as (Plugin & { api?: VueDescriptorMemoryApi }) | undefined
|
||||
)?.api
|
||||
}
|
||||
Loading…
Reference in new issue