mirror of https://github.com/vuejs/vitepress
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1032 lines
31 KiB
1032 lines
31 KiB
import {
|
|
createSsrModuleCompiler,
|
|
type SsrModuleCompiler
|
|
} from 'node/build/ssrModuleCompiler'
|
|
import { SsrModuleArtifactTransport } from 'node/build/ssrModuleTransport'
|
|
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import path from 'node:path'
|
|
import { pathToFileURL } from 'node:url'
|
|
import { perEnvironmentState, type Plugin } from 'vite'
|
|
import { createNodeImportMeta, ModuleRunner } from 'vite/module-runner'
|
|
|
|
describe('SsrModuleCompiler', () => {
|
|
let root: string | undefined
|
|
const compilers = new Set<SsrModuleCompiler>()
|
|
|
|
afterEach(async () => {
|
|
await Promise.all([...compilers].map((compiler) => compiler.close()))
|
|
compilers.clear()
|
|
if (root) {
|
|
await rm(root, { recursive: true, force: true })
|
|
root = undefined
|
|
}
|
|
})
|
|
|
|
async function createFixture() {
|
|
root = await mkdtemp(path.join(tmpdir(), 'vitepress-ssr-modules-'))
|
|
await writeFile(
|
|
path.join(root, 'package.json'),
|
|
JSON.stringify({ type: 'module' })
|
|
)
|
|
return {
|
|
root,
|
|
artifactDir: path.join(root, '.artifacts')
|
|
}
|
|
}
|
|
|
|
test('materializes final asset URLs and externalizes shared runtime bridges', async () => {
|
|
const fixture = await createFixture()
|
|
const bridge = path.join(fixture.root, 'runtime-bridge.mjs')
|
|
await writeFile(bridge, 'export const shared = true')
|
|
|
|
const compiler = createSsrModuleCompiler(
|
|
{
|
|
root: fixture.root,
|
|
logLevel: 'silent',
|
|
plugins: [
|
|
{
|
|
name: 'test:resolve-runtime-bridge',
|
|
resolveId(id) {
|
|
if (id === 'virtual:runtime') return '\0test:runtime'
|
|
if (id === 'virtual:runtime?raw') return '\0test:runtime?raw'
|
|
},
|
|
load(id) {
|
|
if (id === '\0test:runtime?raw') {
|
|
return 'export default "raw-runtime-source"'
|
|
}
|
|
}
|
|
}
|
|
]
|
|
},
|
|
fixture.artifactDir,
|
|
{
|
|
runtimeBridges: new Map([['\0test:runtime', bridge]]),
|
|
resolveAsset: new Map([
|
|
['virtual:logo', '/assets/logo.content-hash.svg']
|
|
])
|
|
}
|
|
)
|
|
compilers.add(compiler)
|
|
await compiler.init()
|
|
|
|
await expect(compiler.handleFetch(['virtual:runtime'])).resolves.toEqual({
|
|
externalize: pathToFileURL(bridge).href,
|
|
type: 'module'
|
|
})
|
|
|
|
const queriedRuntime = await compiler.handleFetch(['virtual:runtime?raw'])
|
|
expect('externalize' in queriedRuntime).toBe(false)
|
|
expect('code' in queriedRuntime).toBe(true)
|
|
if ('code' in queriedRuntime) {
|
|
expect(queriedRuntime.code).toContain('raw-runtime-source')
|
|
}
|
|
|
|
const asset = await compiler.handleFetch(['virtual:logo'])
|
|
expect('cache' in asset).toBe(false)
|
|
expect('code' in asset).toBe(true)
|
|
if ('code' in asset) {
|
|
expect(asset.code).toContain('/assets/logo.content-hash.svg')
|
|
expect(asset.code).not.toContain('/@fs/')
|
|
expect(asset.invalidate).toBe(false)
|
|
}
|
|
})
|
|
|
|
test('runs SSR plugin buildStart hooks before transforming modules', async () => {
|
|
const fixture = await createFixture()
|
|
let initialized = false
|
|
const compiler = createSsrModuleCompiler(
|
|
{
|
|
root: fixture.root,
|
|
logLevel: 'silent',
|
|
plugins: [
|
|
{
|
|
name: 'test:ssr-build-start',
|
|
buildStart() {
|
|
initialized = true
|
|
},
|
|
resolveId(id) {
|
|
if (id === 'virtual:after-build-start') {
|
|
return '\0test:after-build-start'
|
|
}
|
|
},
|
|
load(id) {
|
|
if (id !== '\0test:after-build-start') return
|
|
if (!initialized) throw new Error('buildStart did not run')
|
|
return 'export const initialized = true'
|
|
}
|
|
}
|
|
]
|
|
},
|
|
fixture.artifactDir,
|
|
{ persistArtifacts: false }
|
|
)
|
|
compilers.add(compiler)
|
|
await compiler.init()
|
|
|
|
const result = await compiler.precompile('virtual:after-build-start')
|
|
expect(initialized).toBe(true)
|
|
expect('code' in result && result.code).toContain('initialized')
|
|
})
|
|
|
|
test('presents production non-watch semantics to user plugin hooks', async () => {
|
|
const fixture = await createFixture()
|
|
const observations: {
|
|
hook: string
|
|
mode: string
|
|
watchMode: boolean
|
|
}[] = []
|
|
const environments = new Set<object>()
|
|
const environmentState = perEnvironmentState(() => ({
|
|
hooks: [] as string[]
|
|
}))
|
|
let sharedEnvironmentState: { hooks: string[] } | undefined
|
|
let sawBuildContextSurface = false
|
|
let readModuleMeta = false
|
|
const observe = (
|
|
hook: string,
|
|
context: {
|
|
environment: { mode: string }
|
|
meta: { watchMode: boolean }
|
|
}
|
|
) => {
|
|
const state = environmentState(context as never)
|
|
sharedEnvironmentState ||= state
|
|
expect(state).toBe(sharedEnvironmentState)
|
|
state.hooks.push(hook)
|
|
environments.add(context.environment)
|
|
observations.push({
|
|
hook,
|
|
mode: context.environment.mode,
|
|
watchMode: context.meta.watchMode
|
|
})
|
|
}
|
|
let readCombinedSourcemap = false
|
|
const compiler = createSsrModuleCompiler(
|
|
{
|
|
root: fixture.root,
|
|
logLevel: 'silent',
|
|
plugins: [
|
|
{
|
|
name: 'test:ssr-build-context',
|
|
options(options) {
|
|
observe('options', this)
|
|
expect(this.emitFile).toBeUndefined()
|
|
expect(this.getFileName).toBeUndefined()
|
|
expect(this.getModuleInfo).toBeUndefined()
|
|
expect(this.getModuleIds).toBeUndefined()
|
|
return options
|
|
},
|
|
buildStart() {
|
|
observe('buildStart', this)
|
|
},
|
|
resolveId(id) {
|
|
if (id !== 'virtual:build-context') return
|
|
observe('resolveId', this)
|
|
return '\0test:build-context'
|
|
},
|
|
load(id) {
|
|
if (id !== '\0test:build-context') return
|
|
observe('load', this)
|
|
return 'export const context = true'
|
|
},
|
|
transform(code, id) {
|
|
if (id !== '\0test:build-context') return
|
|
observe('transform', this)
|
|
sawBuildContextSurface =
|
|
typeof this.emitFile === 'function' &&
|
|
typeof this.getFileName === 'function' &&
|
|
typeof this.getModuleInfo === 'function' &&
|
|
typeof this.getModuleIds === 'function' &&
|
|
this.setAssetSource === undefined &&
|
|
this.getWatchFiles === undefined
|
|
readModuleMeta = this.getModuleInfo(id)?.meta != null
|
|
this.getCombinedSourcemap()
|
|
readCombinedSourcemap = true
|
|
return code
|
|
},
|
|
buildEnd() {
|
|
observe('buildEnd', this)
|
|
},
|
|
closeBundle() {
|
|
observe('closeBundle', this)
|
|
}
|
|
}
|
|
]
|
|
},
|
|
fixture.artifactDir,
|
|
{ persistArtifacts: false }
|
|
)
|
|
compilers.add(compiler)
|
|
await compiler.init()
|
|
await compiler.precompile('virtual:build-context')
|
|
await compiler.close()
|
|
|
|
expect(readCombinedSourcemap).toBe(true)
|
|
expect(readModuleMeta).toBe(true)
|
|
expect(sawBuildContextSurface).toBe(true)
|
|
expect(environments.size).toBe(1)
|
|
expect(observations.map(({ hook }) => hook)).toEqual([
|
|
'options',
|
|
'buildStart',
|
|
'resolveId',
|
|
'load',
|
|
'transform',
|
|
'buildEnd',
|
|
'closeBundle'
|
|
])
|
|
expect(
|
|
observations.every(
|
|
({ mode, watchMode }) => mode === 'build' && watchMode === false
|
|
)
|
|
).toBe(true)
|
|
expect(sharedEnvironmentState?.hooks).toEqual(
|
|
observations.map(({ hook }) => hook)
|
|
)
|
|
})
|
|
|
|
test('rejects Rolldown-only context methods instead of ignoring them', async () => {
|
|
const fixture = await createFixture()
|
|
const compiler = createSsrModuleCompiler(
|
|
{
|
|
root: fixture.root,
|
|
logLevel: 'silent',
|
|
plugins: [
|
|
{
|
|
name: 'test:ssr-emit-file',
|
|
resolveId(id) {
|
|
if (id === 'virtual:emit-file') return '\0test:emit-file'
|
|
},
|
|
load(id) {
|
|
if (id !== '\0test:emit-file') return
|
|
this.emitFile({
|
|
type: 'asset',
|
|
name: 'server-only.txt',
|
|
source: 'server-only'
|
|
})
|
|
return 'export const emitted = true'
|
|
}
|
|
}
|
|
]
|
|
},
|
|
fixture.artifactDir,
|
|
{ persistArtifacts: false }
|
|
)
|
|
compilers.add(compiler)
|
|
await compiler.init()
|
|
|
|
await expect(compiler.precompile('virtual:emit-file')).rejects.toThrow(
|
|
'plugin "test:ssr-emit-file" called this.emitFile()'
|
|
)
|
|
})
|
|
|
|
test('runs supported plugin teardown hooks when closing', async () => {
|
|
const fixture = await createFixture()
|
|
const lifecycle: string[] = []
|
|
const compiler = createSsrModuleCompiler(
|
|
{
|
|
root: fixture.root,
|
|
logLevel: 'silent',
|
|
plugins: [
|
|
{
|
|
name: 'test:ssr-teardown-lifecycle',
|
|
buildEnd() {
|
|
lifecycle.push('buildEnd')
|
|
},
|
|
closeBundle() {
|
|
lifecycle.push('closeBundle')
|
|
}
|
|
}
|
|
]
|
|
},
|
|
fixture.artifactDir,
|
|
{ persistArtifacts: false }
|
|
)
|
|
compilers.add(compiler)
|
|
await compiler.init()
|
|
|
|
await compiler.close()
|
|
expect(lifecycle).toEqual(['buildEnd', 'closeBundle'])
|
|
})
|
|
|
|
test('propagates plugin teardown errors after closing the environment', async () => {
|
|
const fixture = await createFixture()
|
|
const compiler = createSsrModuleCompiler(
|
|
{
|
|
root: fixture.root,
|
|
logLevel: 'silent',
|
|
plugins: [
|
|
{
|
|
name: 'test:ssr-teardown-error',
|
|
closeBundle() {
|
|
throw new Error('SSR teardown failed')
|
|
}
|
|
}
|
|
]
|
|
},
|
|
fixture.artifactDir,
|
|
{ persistArtifacts: false }
|
|
)
|
|
compilers.add(compiler)
|
|
await compiler.init()
|
|
|
|
const environment = (
|
|
compiler as unknown as {
|
|
environment: { close: () => Promise<void> }
|
|
}
|
|
).environment
|
|
const closeEnvironment = vi.spyOn(environment, 'close')
|
|
|
|
await expect(compiler.close()).rejects.toThrow('SSR teardown failed')
|
|
expect(closeEnvironment).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
test('rejects output hooks before starting the unbundled page environment', async () => {
|
|
const fixture = await createFixture()
|
|
const compiler = createSsrModuleCompiler(
|
|
{
|
|
root: fixture.root,
|
|
logLevel: 'silent',
|
|
plugins: [
|
|
{
|
|
name: 'test:ssr-page-output',
|
|
renderChunk(code) {
|
|
return code
|
|
}
|
|
}
|
|
]
|
|
},
|
|
fixture.artifactDir,
|
|
{ persistArtifacts: false }
|
|
)
|
|
compilers.add(compiler)
|
|
|
|
await expect(compiler.init()).rejects.toThrow(
|
|
'plugin "test:ssr-page-output": renderChunk'
|
|
)
|
|
})
|
|
|
|
test('rejects output hooks from build.rolldownOptions.plugins once', async () => {
|
|
const fixture = await createFixture()
|
|
const compiler = createSsrModuleCompiler(
|
|
{
|
|
root: fixture.root,
|
|
logLevel: 'silent',
|
|
build: {
|
|
rolldownOptions: {
|
|
plugins: [
|
|
{
|
|
name: 'test:rolldown-page-output',
|
|
augmentChunkHash() {
|
|
return 'page-output'
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}
|
|
},
|
|
fixture.artifactDir,
|
|
{ persistArtifacts: false }
|
|
)
|
|
compilers.add(compiler)
|
|
|
|
const error = await compiler.init().catch((error: unknown) => error)
|
|
expect(error).toBeInstanceOf(Error)
|
|
const message = (error as Error).message
|
|
expect(message).toContain(
|
|
'plugin "test:rolldown-page-output": augmentChunkHash'
|
|
)
|
|
expect(message.match(/test:rolldown-page-output/g)).toHaveLength(1)
|
|
})
|
|
|
|
test('allows bundled-only output hooks excluded from the page environment', async () => {
|
|
const fixture = await createFixture()
|
|
const compiler = createSsrModuleCompiler(
|
|
{
|
|
root: fixture.root,
|
|
logLevel: 'silent',
|
|
plugins: [
|
|
{
|
|
name: 'test:bundled-page-output',
|
|
applyToEnvironment(environment) {
|
|
return environment.config.isBundled
|
|
},
|
|
renderChunk(code) {
|
|
return code
|
|
}
|
|
}
|
|
]
|
|
},
|
|
fixture.artifactDir,
|
|
{ persistArtifacts: false }
|
|
)
|
|
compilers.add(compiler)
|
|
|
|
await expect(compiler.init()).resolves.toBeUndefined()
|
|
})
|
|
|
|
test('deduplicates transforms and serves materialized CAS output to a new compiler', async () => {
|
|
const fixture = await createFixture()
|
|
const virtualId = '\0test:ssr-page'
|
|
let loadCalls = 0
|
|
const sourcePlugin: Plugin = {
|
|
name: 'test:ssr-page-source',
|
|
resolveId(id) {
|
|
if (id === 'virtual:ssr-page') return virtualId
|
|
},
|
|
load(id) {
|
|
if (id === virtualId) {
|
|
loadCalls++
|
|
return 'export const page = "materialized"'
|
|
}
|
|
}
|
|
}
|
|
|
|
const firstCompiler = createSsrModuleCompiler(
|
|
{
|
|
root: fixture.root,
|
|
logLevel: 'silent',
|
|
plugins: [sourcePlugin]
|
|
},
|
|
fixture.artifactDir
|
|
)
|
|
compilers.add(firstCompiler)
|
|
await firstCompiler.init()
|
|
|
|
const [first, concurrent] = await Promise.all([
|
|
firstCompiler.precompile('virtual:ssr-page'),
|
|
firstCompiler.precompile('virtual:ssr-page')
|
|
])
|
|
expect(concurrent).toEqual(first)
|
|
expect(loadCalls).toBe(1)
|
|
expect('cache' in first).toBe(false)
|
|
if ('code' in first) {
|
|
expect(first.code).toContain('materialized')
|
|
expect(first.invalidate).toBe(false)
|
|
}
|
|
|
|
await firstCompiler.close()
|
|
compilers.delete(firstCompiler)
|
|
|
|
const rejectUncachedLoad: Plugin = {
|
|
name: 'test:reject-uncached-ssr-page',
|
|
resolveId(id) {
|
|
if (id === 'virtual:ssr-page') return virtualId
|
|
},
|
|
load(id) {
|
|
if (id === virtualId) {
|
|
throw new Error('expected transformed module to come from the CAS')
|
|
}
|
|
}
|
|
}
|
|
const secondCompiler = createSsrModuleCompiler(
|
|
{
|
|
root: fixture.root,
|
|
logLevel: 'silent',
|
|
plugins: [rejectUncachedLoad]
|
|
},
|
|
fixture.artifactDir
|
|
)
|
|
compilers.add(secondCompiler)
|
|
await secondCompiler.init()
|
|
|
|
await expect(
|
|
secondCompiler.precompile('virtual:ssr-page')
|
|
).resolves.toEqual(first)
|
|
})
|
|
|
|
test('does not persist entries and scopes dependency reuse by importer', async () => {
|
|
const fixture = await createFixture()
|
|
const entryId = '\0test:one-shot-entry'
|
|
const dependencyId = path.join(fixture.root, 'shared-dependency.js')
|
|
const transformedDependencyId = '\0test:shared-dependency'
|
|
|
|
let entryLoads = 0
|
|
let dependencyLoads = 0
|
|
const compiler = createSsrModuleCompiler(
|
|
{
|
|
root: fixture.root,
|
|
logLevel: 'silent',
|
|
plugins: [
|
|
{
|
|
name: 'test:persistence-boundary',
|
|
resolveId(id) {
|
|
if (id === 'virtual:one-shot-entry') return entryId
|
|
if (id === dependencyId) return transformedDependencyId
|
|
},
|
|
load(id) {
|
|
if (id === entryId) {
|
|
entryLoads++
|
|
return 'export const entry = true'
|
|
}
|
|
if (id === transformedDependencyId) {
|
|
dependencyLoads++
|
|
return 'export const dependency = true'
|
|
}
|
|
}
|
|
}
|
|
]
|
|
},
|
|
fixture.artifactDir,
|
|
{ persistEntries: false }
|
|
)
|
|
compilers.add(compiler)
|
|
await compiler.init()
|
|
|
|
await compiler.precompile('virtual:one-shot-entry')
|
|
await compiler.precompile('virtual:one-shot-entry')
|
|
expect(entryLoads).toBe(2)
|
|
|
|
const entryGraph = (
|
|
compiler as unknown as {
|
|
environment: {
|
|
moduleGraph: {
|
|
getModuleById: (id: string) => unknown
|
|
urlToModuleMap: Map<string, { id: string | null }>
|
|
}
|
|
}
|
|
}
|
|
).environment.moduleGraph
|
|
expect(entryGraph.getModuleById(entryId)).toBeUndefined()
|
|
expect(
|
|
[...entryGraph.urlToModuleMap.values()].some(
|
|
(module) => module.id === entryId
|
|
)
|
|
).toBe(false)
|
|
|
|
await compiler.handleFetch([dependencyId, '/first-page.md'])
|
|
await compiler.handleFetch([dependencyId, '/first-page.md'])
|
|
expect(dependencyLoads).toBe(1)
|
|
await compiler.handleFetch([dependencyId, '/second-page.md'])
|
|
expect(dependencyLoads).toBe(2)
|
|
})
|
|
|
|
test('removes absolute one-shot entries across ModuleRunner id spellings', async () => {
|
|
const fixture = await createFixture()
|
|
const entryFile = path.join(fixture.root, 'absolute-entry.js')
|
|
await writeFile(entryFile, 'export const absoluteEntry = true')
|
|
|
|
const compiler = createSsrModuleCompiler(
|
|
{ root: fixture.root, logLevel: 'silent' },
|
|
fixture.artifactDir,
|
|
{ persistEntries: false }
|
|
)
|
|
compilers.add(compiler)
|
|
await compiler.init()
|
|
|
|
const result = await compiler.precompile(entryFile)
|
|
expect('code' in result && result.code).toContain('absoluteEntry')
|
|
|
|
const graph = (
|
|
compiler as unknown as {
|
|
environment: {
|
|
moduleGraph: {
|
|
idToModuleMap: Map<string, { id: string | null }>
|
|
urlToModuleMap: Map<string, { id: string | null }>
|
|
}
|
|
}
|
|
}
|
|
).environment.moduleGraph
|
|
expect(
|
|
[...graph.idToModuleMap.values(), ...graph.urlToModuleMap.values()].some(
|
|
(module) => module.id?.replace(/[?#].*$/, '') === entryFile
|
|
)
|
|
).toBe(false)
|
|
})
|
|
|
|
test('keeps importer-aware resolution and assets distinct for absolute requests', async () => {
|
|
const fixture = await createFixture()
|
|
const firstBridge = path.join(fixture.root, 'runtime-one.mjs')
|
|
const secondBridge = path.join(fixture.root, 'runtime-two.mjs')
|
|
const absoluteId = path.join(fixture.root, 'shared-runtime.js')
|
|
const windowsAbsoluteId = 'C:/docs/shared-runtime.js'
|
|
const fileUrlId = pathToFileURL(
|
|
path.join(fixture.root, 'file-url-runtime.js')
|
|
).href
|
|
const ids = [absoluteId, windowsAbsoluteId, fileUrlId]
|
|
const importerOne = '/first-page.md'
|
|
const importerTwo = '/second-page.md'
|
|
const resolvedIds = new Map(
|
|
ids.map((id, index) => [
|
|
id,
|
|
[`\0test:runtime-${index}-one`, `\0test:runtime-${index}-two`]
|
|
])
|
|
)
|
|
const replacements = new Map<string, string>()
|
|
for (const [one, two] of resolvedIds.values()) {
|
|
replacements.set(one, firstBridge)
|
|
replacements.set(two, secondBridge)
|
|
}
|
|
const absoluteAsset = path.join(fixture.root, 'logo.svg')
|
|
const compiler = createSsrModuleCompiler(
|
|
{
|
|
root: fixture.root,
|
|
logLevel: 'silent',
|
|
plugins: [
|
|
{
|
|
name: 'test:runtime-replacement-ids',
|
|
resolveId(id, importer) {
|
|
const resolved = resolvedIds.get(id)
|
|
if (resolved) {
|
|
return importer === importerOne ? resolved[0] : resolved[1]
|
|
}
|
|
}
|
|
}
|
|
]
|
|
},
|
|
fixture.artifactDir,
|
|
{
|
|
runtimeBridges: replacements,
|
|
resolveAsset(id, importer) {
|
|
if (id !== absoluteAsset && id !== fileUrlId) return
|
|
return importer === importerOne
|
|
? '/assets/logo-one.svg'
|
|
: '/assets/logo-two.svg'
|
|
}
|
|
}
|
|
)
|
|
compilers.add(compiler)
|
|
await compiler.init()
|
|
|
|
for (const id of [absoluteId, windowsAbsoluteId]) {
|
|
await expect(compiler.handleFetch([id, importerOne])).resolves.toEqual({
|
|
externalize: pathToFileURL(firstBridge).href,
|
|
type: 'module'
|
|
})
|
|
await expect(compiler.handleFetch([id, importerTwo])).resolves.toEqual({
|
|
externalize: pathToFileURL(secondBridge).href,
|
|
type: 'module'
|
|
})
|
|
}
|
|
|
|
const firstAsset = await compiler.handleFetch([absoluteAsset, importerOne])
|
|
const secondAsset = await compiler.handleFetch([absoluteAsset, importerTwo])
|
|
expect('code' in firstAsset && firstAsset.code).toContain(
|
|
'/assets/logo-one.svg'
|
|
)
|
|
expect('code' in secondAsset && secondAsset.code).toContain(
|
|
'/assets/logo-two.svg'
|
|
)
|
|
|
|
const firstFileUrlAsset = await compiler.handleFetch([
|
|
fileUrlId,
|
|
importerOne
|
|
])
|
|
const secondFileUrlAsset = await compiler.handleFetch([
|
|
fileUrlId,
|
|
importerTwo
|
|
])
|
|
expect('code' in firstFileUrlAsset && firstFileUrlAsset.code).toContain(
|
|
'/assets/logo-one.svg'
|
|
)
|
|
expect('code' in secondFileUrlAsset && secondFileUrlAsset.code).toContain(
|
|
'/assets/logo-two.svg'
|
|
)
|
|
})
|
|
|
|
test('omits inline sourcemaps and releases transforms without invalidating importers', async () => {
|
|
const fixture = await createFixture()
|
|
const virtualId = '\0test:mapped-module'
|
|
const source = 'export const mapped = true'
|
|
const compiler = createSsrModuleCompiler(
|
|
{
|
|
root: fixture.root,
|
|
logLevel: 'silent',
|
|
plugins: [
|
|
{
|
|
name: 'test:mapped-module',
|
|
resolveId(id) {
|
|
if (id === 'virtual:mapped-module') return virtualId
|
|
},
|
|
load(id) {
|
|
if (id === virtualId) return source
|
|
},
|
|
transform(code, id) {
|
|
if (id !== virtualId) return
|
|
return {
|
|
code,
|
|
map: {
|
|
version: 3,
|
|
names: [],
|
|
sources: ['mapped-source.ts'],
|
|
sourcesContent: [source],
|
|
mappings: 'AAAA'
|
|
}
|
|
}
|
|
}
|
|
}
|
|
]
|
|
},
|
|
fixture.artifactDir,
|
|
{ persistArtifacts: false }
|
|
)
|
|
compilers.add(compiler)
|
|
await compiler.init()
|
|
|
|
const graph = (
|
|
compiler as unknown as {
|
|
environment: {
|
|
moduleGraph: {
|
|
invalidateModule: (...args: unknown[]) => void
|
|
updateModuleTransformResult: (...args: unknown[]) => void
|
|
}
|
|
}
|
|
}
|
|
).environment.moduleGraph
|
|
const invalidate = vi.spyOn(graph, 'invalidateModule')
|
|
const release = vi.spyOn(graph, 'updateModuleTransformResult')
|
|
|
|
const result = await compiler.precompile('virtual:mapped-module')
|
|
expect('code' in result).toBe(true)
|
|
if ('code' in result) {
|
|
expect(result.code).not.toContain('sourceMappingURL=data:')
|
|
expect(result.code).not.toContain('sourceMappingSource=vite-generated')
|
|
expect(result.invalidate).toBe(false)
|
|
}
|
|
expect(invalidate).not.toHaveBeenCalled()
|
|
expect(release).toHaveBeenCalledWith(
|
|
expect.objectContaining({ id: virtualId }),
|
|
null
|
|
)
|
|
})
|
|
|
|
test('waits for accepted fetches before closing the environment', async () => {
|
|
const fixture = await createFixture()
|
|
const virtualId = '\0test:slow-module'
|
|
let finishLoad!: (source: string) => void
|
|
let markLoadStarted!: () => void
|
|
const loadStarted = new Promise<void>((resolve) => {
|
|
markLoadStarted = resolve
|
|
})
|
|
const compiler = createSsrModuleCompiler(
|
|
{
|
|
root: fixture.root,
|
|
logLevel: 'silent',
|
|
plugins: [
|
|
{
|
|
name: 'test:slow-module',
|
|
resolveId(id) {
|
|
if (id === 'virtual:slow-module') return virtualId
|
|
},
|
|
load(id) {
|
|
if (id !== virtualId) return
|
|
markLoadStarted()
|
|
return new Promise<string>((resolve) => {
|
|
finishLoad = resolve
|
|
})
|
|
}
|
|
}
|
|
]
|
|
},
|
|
fixture.artifactDir
|
|
)
|
|
compilers.add(compiler)
|
|
await compiler.init()
|
|
|
|
const fetch = compiler.precompile('virtual:slow-module')
|
|
await loadStarted
|
|
const closing = compiler.close()
|
|
await expect(compiler.precompile('virtual:slow-module')).rejects.toThrow(
|
|
'SSR module compiler is closing'
|
|
)
|
|
|
|
finishLoad('export const slow = true')
|
|
await expect(fetch).resolves.toEqual(
|
|
expect.objectContaining({ invalidate: false })
|
|
)
|
|
await closing
|
|
})
|
|
|
|
test('materializes page graphs for an offline ModuleRunner', async () => {
|
|
const fixture = await createFixture()
|
|
const entry = path.join(fixture.root, 'page.js')
|
|
const dependency = path.join(fixture.root, 'dependency.js')
|
|
const dynamicDependency = path.join(fixture.root, 'dynamic.js')
|
|
await Promise.all([
|
|
writeFile(
|
|
entry,
|
|
[
|
|
"import { dependency } from './dependency.js'",
|
|
'export const value = `page:${dependency}`',
|
|
"export const loadDynamic = () => import('./dynamic.js')"
|
|
].join('\n')
|
|
),
|
|
writeFile(dependency, 'export const dependency = "shared"'),
|
|
writeFile(dynamicDependency, 'export const dynamic = "loaded"')
|
|
])
|
|
|
|
const compiler = createSsrModuleCompiler(
|
|
{ root: fixture.root, logLevel: 'silent' },
|
|
fixture.artifactDir,
|
|
{ persistEntries: true, releaseEntries: true }
|
|
)
|
|
compilers.add(compiler)
|
|
await compiler.init()
|
|
|
|
const materialized = await compiler.materializeGraphs([entry], 2)
|
|
expect(materialized.entries).toBe(1)
|
|
expect(materialized.requests).toBe(3)
|
|
const builtins = compiler.getBuiltins()
|
|
await compiler.close()
|
|
compilers.delete(compiler)
|
|
|
|
const runner = new ModuleRunner({
|
|
transport: new SsrModuleArtifactTransport(fixture.artifactDir, builtins),
|
|
hmr: false,
|
|
createImportMeta: createNodeImportMeta,
|
|
sourcemapInterceptor: false
|
|
})
|
|
try {
|
|
const page = (await runner.import(entry)) as {
|
|
value: string
|
|
loadDynamic: () => Promise<{ dynamic: string }>
|
|
}
|
|
expect(page.value).toBe('page:shared')
|
|
await expect(page.loadDynamic()).resolves.toMatchObject({
|
|
dynamic: 'loaded'
|
|
})
|
|
} finally {
|
|
await runner.close()
|
|
}
|
|
})
|
|
|
|
test('uses ModuleRunner file identity for query-module dependencies', async () => {
|
|
const fixture = await createFixture()
|
|
const entry = path.join(fixture.root, 'page.js')
|
|
const script = path.join(fixture.root, 'script.js')
|
|
const dependency = path.join(fixture.root, 'dependency.js')
|
|
await Promise.all([
|
|
writeFile(
|
|
entry,
|
|
"import { value } from './script.js?part'\nexport { value }"
|
|
),
|
|
writeFile(
|
|
script,
|
|
"import { dependency } from './dependency.js'\nexport const value = `query:${dependency}`"
|
|
),
|
|
writeFile(dependency, 'export const dependency = "shared"')
|
|
])
|
|
|
|
const compiler = createSsrModuleCompiler(
|
|
{ root: fixture.root, logLevel: 'silent' },
|
|
fixture.artifactDir,
|
|
{ persistEntries: true, releaseEntries: true }
|
|
)
|
|
compilers.add(compiler)
|
|
await compiler.init()
|
|
await compiler.materializeGraphs([entry])
|
|
|
|
const builtins = compiler.getBuiltins()
|
|
await compiler.close()
|
|
compilers.delete(compiler)
|
|
|
|
const runner = new ModuleRunner({
|
|
transport: new SsrModuleArtifactTransport(fixture.artifactDir, builtins),
|
|
hmr: false,
|
|
createImportMeta: createNodeImportMeta,
|
|
sourcemapInterceptor: false
|
|
})
|
|
try {
|
|
await expect(runner.import(entry)).resolves.toMatchObject({
|
|
value: 'query:shared'
|
|
})
|
|
} finally {
|
|
await runner.close()
|
|
}
|
|
})
|
|
|
|
test('publishes only the module closure reachable by one worker batch', async () => {
|
|
const fixture = await createFixture()
|
|
const pageA = path.join(fixture.root, 'page-a.js')
|
|
const pageB = path.join(fixture.root, 'page-b.js')
|
|
const shared = path.join(fixture.root, 'shared.js')
|
|
const dynamicA = path.join(fixture.root, 'dynamic-a.js')
|
|
const onlyB = path.join(fixture.root, 'only-b.js')
|
|
await Promise.all([
|
|
writeFile(
|
|
pageA,
|
|
[
|
|
"import { shared } from './shared.js'",
|
|
'export const value = `a:${shared}`',
|
|
"export const loadDynamic = () => import('./dynamic-a.js')"
|
|
].join('\n')
|
|
),
|
|
writeFile(
|
|
pageB,
|
|
[
|
|
"import { shared } from './shared.js'",
|
|
"import { onlyB } from './only-b.js'",
|
|
'export const value = `b:${shared}:${onlyB}`'
|
|
].join('\n')
|
|
),
|
|
writeFile(shared, 'export const shared = "shared"'),
|
|
writeFile(dynamicA, 'export const dynamicA = "dynamic-a"'),
|
|
writeFile(onlyB, 'export const onlyB = "only-b"')
|
|
])
|
|
|
|
const compiler = createSsrModuleCompiler(
|
|
{ root: fixture.root, logLevel: 'silent' },
|
|
fixture.artifactDir,
|
|
{
|
|
persistEntries: true,
|
|
releaseEntries: true,
|
|
publishFullSnapshot: false
|
|
}
|
|
)
|
|
compilers.add(compiler)
|
|
await compiler.init()
|
|
const materialized = await compiler.materializeGraphs([pageA, pageB], 2)
|
|
|
|
const batchSnapshot = path.join(
|
|
fixture.artifactDir,
|
|
'snapshots',
|
|
'page-a.json'
|
|
)
|
|
const requestCount = await compiler.writeSnapshotForEntries(
|
|
[pageA],
|
|
batchSnapshot
|
|
)
|
|
const slicedSnapshot = JSON.parse(
|
|
await readFile(batchSnapshot, 'utf8')
|
|
) as { requests: [string, string][] }
|
|
expect(slicedSnapshot.requests).toHaveLength(requestCount)
|
|
expect(requestCount).toBeLessThan(materialized.requests)
|
|
await expect(
|
|
readFile(path.join(fixture.artifactDir, 'snapshot.json'), 'utf8')
|
|
).rejects.toMatchObject({ code: 'ENOENT' })
|
|
|
|
const builtins = compiler.getBuiltins()
|
|
await compiler.close()
|
|
compilers.delete(compiler)
|
|
|
|
const runner = new ModuleRunner({
|
|
transport: new SsrModuleArtifactTransport(
|
|
fixture.artifactDir,
|
|
builtins,
|
|
batchSnapshot
|
|
),
|
|
hmr: false,
|
|
createImportMeta: createNodeImportMeta,
|
|
sourcemapInterceptor: false
|
|
})
|
|
try {
|
|
const page = (await runner.import(pageA)) as {
|
|
value: string
|
|
loadDynamic: () => Promise<{ dynamicA: string }>
|
|
}
|
|
expect(page.value).toBe('a:shared')
|
|
await expect(page.loadDynamic()).resolves.toMatchObject({
|
|
dynamicA: 'dynamic-a'
|
|
})
|
|
|
|
// Pointer files for page B exist in the shared store, but an explicit
|
|
// batch snapshot is authoritative and cannot escape its closure.
|
|
await expect(runner.import(pageB)).rejects.toThrow(
|
|
/Missing precompiled SSR module/
|
|
)
|
|
} finally {
|
|
await runner.close()
|
|
}
|
|
})
|
|
|
|
test('rejects runtime-computed imports before offline rendering', async () => {
|
|
const fixture = await createFixture()
|
|
const entry = path.join(fixture.root, 'computed.js')
|
|
await writeFile(
|
|
entry,
|
|
[
|
|
"const target = './dependency.js'",
|
|
'export const load = () => import(target)'
|
|
].join('\n')
|
|
)
|
|
|
|
const compiler = createSsrModuleCompiler(
|
|
{ root: fixture.root, logLevel: 'silent' },
|
|
fixture.artifactDir,
|
|
{ persistEntries: true, releaseEntries: true }
|
|
)
|
|
compilers.add(compiler)
|
|
await compiler.init()
|
|
|
|
await expect(compiler.materializeGraphs([entry])).rejects.toThrow(
|
|
/runtime-computed import/
|
|
)
|
|
})
|
|
|
|
test('reports a missing offline module with its importer', async () => {
|
|
const fixture = await createFixture()
|
|
const transport = new SsrModuleArtifactTransport(fixture.artifactDir, [])
|
|
|
|
await expect(
|
|
transport.invoke({
|
|
type: 'custom',
|
|
event: 'vite:invoke',
|
|
data: {
|
|
name: 'fetchModule',
|
|
data: ['./missing.js', '/page.js', {}]
|
|
}
|
|
})
|
|
).rejects.toThrow(/\.\/missing\.js.*\/page\.js/)
|
|
})
|
|
})
|