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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 4 additions & 44 deletions test/e2e/bundle.test.ts
Original file line number Diff line number Diff line change
@@ -1,50 +1,10 @@
import { readdirSync, readFileSync } from 'node:fs'
import { isBuiltin } from 'node:module'
import { join, relative } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { init, parse } from 'es-module-lexer'
import { resolveModuleURL } from 'exsolve'
import { expect, it } from 'vitest'
import { fileURLToPath } from 'node:url'
import { it } from 'vitest'
import { expectSelfContainedBundle } from '../server-bundle'

const serverDir = fileURLToPath(new URL('../../playground/.output/server/', import.meta.url))

// Optional peers imported behind a try/catch, absent unless a site installs them.
const OPTIONAL_IMPORTS = new Set([
// `@nuxtjs/mcp-toolkit` Code Mode.
'secure-exec',
])

/** The bundled chunks: the traced `node_modules/` are third-party files Nitro copied as they are. */
function bundledFiles(): string[] {
return readdirSync(serverDir, { withFileTypes: true, recursive: true })
.filter((entry) => entry.isFile() && entry.name.endsWith('.mjs'))
.map((entry) => join(entry.parentPath, entry.name))
.filter((file) => !relative(serverDir, file).startsWith('node_modules'))
}

// A lazy chunk (a component, a route) only fails once requested, so the route crawl alone can miss it.
it('every import in the server bundle resolves inside the build output', async () => {
await init()
const files = bundledFiles()
const broken: string[] = []
let checked = 0

for (const file of files) {
const [imports] = parse(readFileSync(file, 'utf8'))
for (const entry of imports) {
// `import.meta`, computed dynamic imports and template globs name no single module.
const specifier = entry.type === 'dynamic' && entry.glob ? undefined : entry.specifier
if (!specifier || isBuiltin(specifier) || OPTIONAL_IMPORTS.has(specifier)) continue
checked++
const resolved = resolveModuleURL(specifier, { from: pathToFileURL(file), try: true })
// Resolving into the repo's own `node_modules` works locally but not once deployed.
if (!resolved?.startsWith(pathToFileURL(serverDir).href)) {
broken.push(`${relative(serverDir, file)} imports ${specifier}`)
}
}
}

// Guards against a vacuous pass, e.g. the lexer reporting no specifiers after an API change.
expect(checked).toBeGreaterThan(files.length)
expect(broken).toEqual([])
await expectSelfContainedBundle(serverDir)
})
48 changes: 48 additions & 0 deletions test/server-bundle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { readdirSync, readFileSync } from 'node:fs'
import { isBuiltin } from 'node:module'
import { join, relative } from 'node:path'
import { pathToFileURL } from 'node:url'
import { init, parse } from 'es-module-lexer'
import { resolveModuleURL } from 'exsolve'
import { expect } from 'vitest'

// Optional peers imported behind a try/catch, absent unless a site installs them.
const OPTIONAL_IMPORTS = new Set([
// `@nuxtjs/mcp-toolkit` Code Mode.
'secure-exec',
])

/** The bundled chunks: the traced `node_modules/` are third-party files Nitro copied as they are. */
function bundledFiles(serverDir: string): string[] {
return readdirSync(serverDir, { withFileTypes: true, recursive: true })
.filter((entry) => entry.isFile() && entry.name.endsWith('.mjs'))
.map((entry) => join(entry.parentPath, entry.name))
.filter((file) => !relative(serverDir, file).startsWith('node_modules'))
}

/** Asserts every import in a Nitro server bundle resolves inside `serverDir`. */
export async function expectSelfContainedBundle(serverDir: string) {
await init()
const files = bundledFiles(serverDir)
const broken: string[] = []
let checked = 0

for (const file of files) {
const [imports] = parse(readFileSync(file, 'utf8'))
for (const entry of imports) {
// `import.meta`, computed dynamic imports and template globs name no single module.
const specifier = entry.type === 'dynamic' && entry.glob ? undefined : entry.specifier
if (!specifier || isBuiltin(specifier) || OPTIONAL_IMPORTS.has(specifier)) continue
checked++
const resolved = resolveModuleURL(specifier, { from: pathToFileURL(file), try: true })
// Resolving into the repo's own `node_modules` works locally but not once deployed.
if (!resolved?.startsWith(pathToFileURL(serverDir).href)) {
broken.push(`${relative(serverDir, file)} imports ${specifier}`)
}
}
}

// Guards against a vacuous pass, e.g. the lexer reporting no specifiers after an API change.
expect(checked).toBeGreaterThan(files.length)
expect(broken).toEqual([])
}
6 changes: 6 additions & 0 deletions test/vercel/output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { existsSync, lstatSync, readdirSync, readFileSync } from 'node:fs'
import { join, relative } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { expectSelfContainedBundle } from '../server-bundle'

const outputDir = fileURLToPath(new URL('../../playground/.vercel/output/', import.meta.url))
const functionsDir = join(outputDir, 'functions')
Expand Down Expand Up @@ -151,6 +152,11 @@ describe('server function', () => {
expect(bytes / 1024 / 1024).toBeLessThan(MAX_FUNCTION_MB)
})

// Vercel uploads the function directory alone, with its own traced `node_modules/`.
it('resolves every import inside the function', async () => {
await expectSelfContainedBundle(serverDir)
})

// Without it a cold start walks the content repository on GitHub.
it('bundles the content snapshot and its manifest, stamped with one commit', async () => {
const snapshot = await readRawAsset<{ ref: string; items: Array<{ path: string }> }>('snapshot')
Expand Down
Loading