Skip to content
Open
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
5 changes: 5 additions & 0 deletions packages/devtools/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,15 +64,19 @@
"destr": "catalog:prod",
"devframe": "catalog:prod",
"error-stack-parser-es": "catalog:frontend",
"escape-string-regexp": "catalog:prod",
"fast-npm-meta": "catalog:prod",
"hookable": "catalog:frontend",
"image-meta": "catalog:prod",
"local-pkg": "catalog:prod",
"magic-string": "catalog:prod",
"magicast": "catalog:prod",
"mlly": "catalog:prod",
"pathe": "catalog:frontend",
"perfect-debounce": "catalog:frontend",
"pkg-types": "catalog:prod",
"tinyglobby": "catalog:prod",
"unplugin": "catalog:prod",
"unstorage": "catalog:prod",
"verkit": "catalog:prod",
"vite-plugin-vue-devtools": "catalog:prod",
Expand All @@ -89,6 +93,7 @@
"@iconify-json/tabler": "catalog:icons",
"@nuxt/devtools-assets": "workspace:*",
"@nuxt/kit": "catalog:prod",
"@nuxt/schema": "catalog:types",
"@parcel/watcher": "catalog:buildtools",
"@types/markdown-it-link-attributes": "catalog:types",
"@unhead/schema": "catalog:types",
Expand Down
112 changes: 112 additions & 0 deletions packages/devtools/src/integrations/timeline-wrap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import type { SourceMap } from 'magic-string'
import escapeRE from 'escape-string-regexp'
import MagicString from 'magic-string'
import { findStaticImports, parseStaticImport } from 'mlly'
import { createUnplugin } from 'unplugin'
import { runtimeDir } from '../dirs'

const HELPER_NAME = '__nuxtTimelineWrap'
const RENAME_PREFIX = '_$__'
// our own runtime calls these composables too, keep them out of the user's timeline
const RUNTIME_DIR_RE = new RegExp(`^${escapeRE(runtimeDir)}`)

export function importKey(source: string, name: string): string {
return `${source}\0${name}`
}

export interface TimelineWrapOptions {
helperPath: string
/** Keys built with {@link importKey} for every import that should be wrapped. */
getWrappable: () => Promise<ReadonlySet<string>>
}

// must run AFTER Nuxt's key injection and keyed function factory macro rewriting,
// so the caller is responsible for registering it after those plugins
export function TimelineWrapPlugin(options: TimelineWrapOptions) {
return createUnplugin(() => ({
name: 'nuxt:devtools:timeline-function-wrap',
enforce: 'post',
transform: {
filter: {
id: {
include: [/^[^?]*\.(?:m?[jt]sx?|vue)(?:$|\?)/],
exclude: [
/^\0/,
/[\\/]node_modules[\\/]/,
/[?&]macro=true/,
/[?&]type=(?:style|template|custom)\b/,
RUNTIME_DIR_RE,
],
},
code: { include: /\bimport\b/ },
},
async handler(code) {
const wrappable = await options.getWrappable()
if (!wrappable.size)
return
return wrapTimelineImports(code, wrappable, options.helperPath)
},
},
}))
}

export function wrapTimelineImports(
code: string,
wrappable: ReadonlySet<string>,
helperPath: string,
): { code: string, map: SourceMap } | undefined {
if (code.includes(HELPER_NAME))
return

const s = new MagicString(code)
const wrappers: string[] = []

for (const imp of findStaticImports(code)) {
const { specifier, defaultImport, namespacedImport, namedImports } = parseStaticImport(imp)
let wrapped = false

// the local binding to import under: renamed when wrapped so the original name can hold the wrapper
const importAs = (name: string, local: string): string => {
if (!wrappable.has(importKey(specifier, name)))
return local
wrapped = true
// a default import has no meaningful exported name, record it under its local name
wrappers.push(`const ${local} = ${HELPER_NAME}(${JSON.stringify(name === 'default' ? local : name)}, ${RENAME_PREFIX}${local});`)
return RENAME_PREFIX + local
}

const clause: string[] = []
if (defaultImport)
clause.push(importAs('default', defaultImport))
if (namespacedImport)
clause.push(`* as ${namespacedImport}`)
const named = Object.entries(namedImports ?? {}).map(([name, local]) => {
const bound = importAs(name, local)
return name === bound ? name : `${name} as ${bound}`
})

if (named.length)
clause.push(`{ ${named.join(', ')} }`)

if (!wrapped)
continue

// rebuild only the clause between `import` and `from`, keeping the specifier untouched
const clauseStart = imp.start + imp.code.indexOf(imp.imports)
s.overwrite(clauseStart, clauseStart + imp.imports.length, `${clause.join(', ')} `)
}

if (!wrappers.length)
return

s.prepend([
`import { ${HELPER_NAME} } from ${JSON.stringify(helperPath)};`,
...wrappers,
'',
].join('\n'))

return {
code: s.toString(),
map: s.generateMap({ hires: 'boundary' }),
}
}
71 changes: 26 additions & 45 deletions packages/devtools/src/integrations/timeline.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { Import } from 'unimport'
import type { Import, Unimport } from 'unimport'
import type { NuxtDevtoolsServerContext } from '../types'
import { addBuildPlugin } from '@nuxt/kit'
import { resolve } from 'pathe'
import { isGreaterOrEqual } from 'verkit'
import { runtimeDir } from '../dirs'
import { importKey, TimelineWrapPlugin } from './timeline-wrap'

const DEFINE_UPPER_RE = /^define[A-Z]/

Expand Down Expand Up @@ -34,50 +35,30 @@ export function setup({ nuxt, options }: NuxtDevtoolsServerContext) {
return true
}

nuxt.hook('imports:context', (unimport) => {
const ctx = unimport.getInternalContext()

if (!ctx.version || !isGreaterOrEqual(ctx.version, '3.1.0'))
throw new Error(`[Nuxt DevTools] The timeline feature requires \`unimport\` >= v3.1.0, but got \`${ctx.version || '(unknown)'}\`. Please upgrade using \`nuxi upgrade --force\`.`)

ctx.addons.push(
{
injectImportsResolved(imports, _code, id) {
if (id?.includes('?macro=true'))
return
return imports.map((i) => {
if (!filter(i))
return i

const name = i.as || i.name
let unimport: Unimport | undefined
nuxt.hook('imports:context', (ctx) => {
unimport = ctx
})

return {
...i,
meta: {
wrapperOriginalAs: name,
},
as: `_$__${name}`,
}
})
},
injectImportsStringified(str, imports, s, id) {
if (id?.includes('?macro=true'))
return
const code = s.toString()
const injected = imports.filter(i => i.meta?.wrapperOriginalAs)
if (injected.length) {
const result = [
str,
code.includes('__nuxtTimelineWrap')
? ''
: `import { __nuxtTimelineWrap } from ${JSON.stringify(helperPath)}`,
...injected.map(i => `const ${i.meta!.wrapperOriginalAs} = __nuxtTimelineWrap(${JSON.stringify(i.name)}, ${i.as})`),
'',
].join(';')
return result
}
},
},
async function getWrappable(): Promise<ReadonlySet<string>> {
if (!unimport)
return new Set()
const imports = await unimport.getImports()
// keyed function factories (`createUseFetch`, etc.) are compiler macros, calling them through a wrapper breaks them
// (optional chaining: the option only exists since Nuxt 4.4)
const factoryNames = new Set(nuxt.options.optimization.keyedComposableFactories?.map(f => f.name))
return new Set(
imports
.filter(i => filter(i) && !factoryNames.has(i.name))
.map(i => importKey(i.from, i.name)),
)
}

// Nuxt registers its key injection plugins in `build:before` during core module setup, which runs after
// this module's setup, so ours is queued from `modules:done` to land after them
nuxt.hook('modules:done', () => {
nuxt.hook('build:before', () => {
addBuildPlugin(TimelineWrapPlugin({ helperPath, getWrappable }))
})
})
}
Loading
Loading