From 3359c9f3736d99406d5747e5fdf4bb831d2a0938 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Fri, 9 Oct 2026 07:17:46 +0000 Subject: [PATCH 1/3] feat(devtools)!: slim the install size of @nuxt/devtools - stop the client build from leaking server sourcemaps into the published module dist - use the unstorage that Nitro brings, declared as a relaxed optional peer - remove the legacy static assets RPCs (and image-meta); the Assets tab is @devframes/plugin-assets - read auto-imports from the unimport context instead of inlining its preset resolver - drop the dead getMainPackageJSON (pkg-types) and the ofetch fetch shim - remove duplicated devDependencies --- docs/content/2.module/3.migration-v4.md | 3 +- .../devtools-kit/src/_types/integrations.ts | 27 --- packages/devtools-kit/src/_types/rpc.ts | 12 +- packages/devtools/client/nuxt.config.ts | 6 + packages/devtools/package.json | 10 +- packages/devtools/src/integrations/assets.ts | 3 +- packages/devtools/src/npm/index.ts | 10 +- packages/devtools/src/server-rpc/assets.ts | 228 ------------------ packages/devtools/src/server-rpc/general.ts | 24 +- packages/devtools/src/server-rpc/index.ts | 2 - .../devtools/src/server-rpc/storage-watch.ts | 2 +- packages/devtools/src/server-rpc/storage.ts | 22 +- packages/devtools/test/assets-rpc.test.ts | 74 ------ .../devtools/test/write-static-assets.test.ts | 87 ------- pnpm-lock.yaml | 21 +- pnpm-workspace.yaml | 3 +- 16 files changed, 46 insertions(+), 488 deletions(-) delete mode 100644 packages/devtools/src/server-rpc/assets.ts delete mode 100644 packages/devtools/test/assets-rpc.test.ts delete mode 100644 packages/devtools/test/write-static-assets.test.ts diff --git a/docs/content/2.module/3.migration-v4.md b/docs/content/2.module/3.migration-v4.md index d42177a39e..d3303d4016 100644 --- a/docs/content/2.module/3.migration-v4.md +++ b/docs/content/2.module/3.migration-v4.md @@ -303,7 +303,8 @@ The built-in VS Code integration is replaced by the Code Server plugin (`devtool - Global installs: `devtoolsGlobal`, the `-g` install flag and `NuxtDevtoolsInfo.isGlobalInstall`. - The `viteDevTools` module option; Vite DevTools is always on. On Vite 8.3+ Nuxt DevTools configures Vite's top-level `devtools` option, so `vite: { devtools: false }` turns it off. - `client.devtools.open()` / `close()` / `toggle()` still exist and now drive the Vite DevTools panel. +- The static assets RPCs `getStaticAssets`, `getImageMeta`, `getTextAssetContent`, `writeStaticAssets`, `deleteStaticAsset` and `renameStaticAsset`, together with the `AssetInfo`, `AssetEntry`, `AssetType` and `ImageMeta` types. The Assets tab is now [`@devframes/plugin-assets`](https://npmx.dev/package/@devframes/plugin-assets). ### Peer dependencies -`@nuxt/devtools` requires Vite `^8.1.5` (was `>=6.0`) and `@nuxt/kit` 4 or 5. Nitro v2 (`nitropack`, Nuxt 4) and Nitro v3 (`nitro`, Nuxt 5) are both optional peers; the one your user's Nuxt installs is the one that gets used. +`@nuxt/devtools` requires Vite `^8.1.5` (was `>=6.0`) and `@nuxt/kit` 4 or 5. Nitro v2 (`nitropack`, Nuxt 4) and Nitro v3 (`nitro`, Nuxt 5) are both optional peers; the one your user's Nuxt installs is the one that gets used. `unstorage` is an optional peer for the same reason: the Storage tab uses the copy that Nitro brings. diff --git a/packages/devtools-kit/src/_types/integrations.ts b/packages/devtools-kit/src/_types/integrations.ts index 1bb85ca41c..fca33871f3 100644 --- a/packages/devtools-kit/src/_types/integrations.ts +++ b/packages/devtools-kit/src/_types/integrations.ts @@ -11,14 +11,6 @@ export interface HookInfo { executions: number[] } -export interface ImageMeta { - width: number - height: number - orientation?: number - type?: string - mimeType?: string -} - export interface PackageUpdateInfo { name: string current: string @@ -196,25 +188,6 @@ export interface VueInspectorClient { export type VueInspectorData = VueInspectorClient['linkParams'] & Partial -export type AssetType = 'image' | 'font' | 'video' | 'audio' | 'text' | 'json' | 'other' - -export interface AssetInfo { - path: string - type: AssetType - publicPath: string - filePath: string - size: number - mtime: number - layer?: string -} - -export interface AssetEntry { - path: string - content: string - encoding?: BufferEncoding - override?: boolean -} - export interface CodeSnippet { code: string lang: string diff --git a/packages/devtools-kit/src/_types/rpc.ts b/packages/devtools-kit/src/_types/rpc.ts index 20476862d2..e50ea3397b 100644 --- a/packages/devtools-kit/src/_types/rpc.ts +++ b/packages/devtools-kit/src/_types/rpc.ts @@ -3,7 +3,7 @@ import type { StorageValue } from 'unstorage' import type { ResolvedConfig } from 'vite' import type { AnalyzeBuildsInfo } from './analyze-build' import type { ModuleCustomTab } from './custom-tabs' -import type { AssetEntry, AssetInfo, AutoImportsWithMetadata, ComponentRelationship, HookInfo, ImageMeta, NpmCommandOptions, NpmCommandType, PackageUpdateInfo, ScannedNitroTasks, ServerRouteInfo } from './integrations' +import type { AutoImportsWithMetadata, ComponentRelationship, HookInfo, NpmCommandOptions, NpmCommandType, PackageUpdateInfo, ScannedNitroTasks, ServerRouteInfo } from './integrations' import type { AnyNitro, AnyStorageMounts } from './nitro-compat' import type { NuxtDevtoolsNotifyInput } from './notify' import type { ModuleOptions, NuxtDevToolsOptions } from './options' @@ -23,12 +23,11 @@ export interface ServerFunctions { getModuleOptions: () => ModuleOptions getComponents: () => Component[] getComponentsRelationships: () => Promise - getAutoImports: () => AutoImportsWithMetadata + getAutoImports: () => Promise getServerPages: () => NuxtPage[] getCustomTabs: () => ModuleCustomTab[] getServerHooks: () => HookInfo[] getServerLayouts: () => NuxtLayout[] - getStaticAssets: () => Promise getServerRoutes: () => ServerRouteInfo[] getServerTasks: () => ScannedNitroTasks | null getServerApp: () => NuxtApp | undefined @@ -59,13 +58,6 @@ export interface ServerFunctions { startAnalyzeBuild: (name: string) => Promise clearAnalyzeBuilds: (names?: string[]) => Promise - // Queries - getImageMeta: (filepath: string) => Promise - getTextAssetContent: (filepath: string, limit?: number) => Promise - writeStaticAssets: (file: AssetEntry[], folder: string) => Promise - deleteStaticAsset: (filepath: string) => Promise - renameStaticAsset: (oldPath: string, newPath: string) => Promise - // Notifications notify: (input: NuxtDevtoolsNotifyInput) => Promise diff --git a/packages/devtools/client/nuxt.config.ts b/packages/devtools/client/nuxt.config.ts index 4a439827cc..3d1005003d 100644 --- a/packages/devtools/client/nuxt.config.ts +++ b/packages/devtools/client/nuxt.config.ts @@ -82,6 +82,12 @@ export default defineNuxtConfig({ '@nuxt/devtools-kit': resolver.resolve('../../devtools-kit/src/index'), }, + // A static SPA has no use for server sourcemaps, and Nuxt's Vite builder + // writes them to `/dist`, i.e. into the published `@nuxt/devtools` dist. + sourcemap: { + server: false, + }, + experimental: { watcher: 'parcel', payloadExtraction: 'client', diff --git a/packages/devtools/package.json b/packages/devtools/package.json index 4a5b1a4f84..a5bccb3f14 100644 --- a/packages/devtools/package.json +++ b/packages/devtools/package.json @@ -35,6 +35,7 @@ "@nuxt/kit": "^4.0.0-0 || ^5.0.0-0", "nitro": "*", "nitropack": "^2.0.0", + "unstorage": "^1.0.0 || ^2.0.0-0", "vite": "^8.1.5", "vite-plugin-inspect": "catalog:prod" }, @@ -45,6 +46,9 @@ "nitropack": { "optional": true }, + "unstorage": { + "optional": true + }, "vite-plugin-inspect": { "optional": true } @@ -66,17 +70,14 @@ "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", "vite-plugin-vue-tracer": "catalog:prod" @@ -90,7 +91,6 @@ "@iconify-json/ri": "catalog:icons", "@iconify-json/simple-icons": "catalog:icons", "@iconify-json/tabler": "catalog:icons", - "@nuxt/devtools-assets": "workspace:*", "@nuxt/kit": "catalog:prod", "@nuxt/schema": "catalog:types", "@parcel/watcher": "catalog:buildtools", @@ -98,7 +98,6 @@ "@unocss/nuxt": "catalog:buildtools", "@unocss/preset-icons": "catalog:buildtools", "@unocss/preset-uno": "catalog:buildtools", - "@vitejs/devtools": "catalog:buildtools", "@vueuse/nuxt": "catalog:buildtools", "cronstrue": "catalog:frontend", "diff": "catalog:frontend", @@ -128,6 +127,7 @@ "unocss": "catalog:buildtools", "unplugin-vue": "catalog:buildtools", "unplugin-vue-markdown": "catalog:buildtools", + "unstorage": "catalog:types", "vanilla-jsoneditor": "catalog:frontend", "vis-data": "catalog:frontend", "vis-network": "catalog:frontend", diff --git a/packages/devtools/src/integrations/assets.ts b/packages/devtools/src/integrations/assets.ts index 91414dd4d3..a6da39bff6 100644 --- a/packages/devtools/src/integrations/assets.ts +++ b/packages/devtools/src/integrations/assets.ts @@ -6,8 +6,7 @@ import { defaultAllowedExtensions } from '../constant' /** * Mount the Assets Devframe into the Nuxt dock group. It replaces the removed - * built-in Assets tab; the legacy asset RPC functions stay for modules that - * still call them. + * built-in Assets tab. */ export function setup({ nuxt, options }: NuxtDevtoolsServerContext): void { const definition = createAssetsDevframe({ diff --git a/packages/devtools/src/npm/index.ts b/packages/devtools/src/npm/index.ts index 0b0fa8ff62..70d2dae878 100644 --- a/packages/devtools/src/npm/index.ts +++ b/packages/devtools/src/npm/index.ts @@ -2,14 +2,8 @@ import type { PackageUpdateInfo } from '../types' import { createRequire } from 'node:module' import { logger, useNuxt } from '@nuxt/kit' import { getPackageInfo } from 'local-pkg' -import { fetch } from 'ofetch' -import { readPackageJSON } from 'pkg-types' import { isLessThan } from 'verkit' -export async function getMainPackageJSON(nuxt = useNuxt()) { - return readPackageJSON(nuxt.options.rootDir) -} - export async function checkForUpdateOf(name: string, current?: string, nuxt = useNuxt()): Promise { try { if (!current) { @@ -28,9 +22,7 @@ export async function checkForUpdateOf(name: string, current?: string, nuxt = us return const { getLatestVersion } = await import('fast-npm-meta') - const { version: latest } = await getLatestVersion(name, { - fetch, - }) + const { version: latest } = await getLatestVersion(name) const needsUpdate = !!latest && latest !== current && isLessThan(current, latest) diff --git a/packages/devtools/src/server-rpc/assets.ts b/packages/devtools/src/server-rpc/assets.ts deleted file mode 100644 index 30c9b49ccc..0000000000 --- a/packages/devtools/src/server-rpc/assets.ts +++ /dev/null @@ -1,228 +0,0 @@ -import type { AssetEntry, AssetInfo, AssetType, ImageMeta, NuxtDevtoolsServerContext, ServerFunctions } from '../types' -import { existsSync } from 'node:fs' -import fsp from 'node:fs/promises' -import { parse, relative } from 'node:path' -import { imageMeta } from 'image-meta' -import { dirname, extname, join, resolve } from 'pathe' -import { debounce } from 'perfect-debounce' -import { glob } from 'tinyglobby' -import { defaultAllowedExtensions } from '../constant' - -const MAX_TEXT_PREVIEW = 10_000 - -export function setupAssetsRPC({ nuxt, refresh, options }: NuxtDevtoolsServerContext) { - const _imageMetaCache = new Map() - let cache: AssetInfo[] | null = null - - const extensions = options.assets?.uploadExtensions || defaultAllowedExtensions - const publicDir = resolve(nuxt.options.srcDir, nuxt.options.dir.public) - const layerDirs = [publicDir, ...nuxt.options._layers.map(layer => resolve(layer.cwd, 'public'))] - - // Every path-taking RPC below is called from the browser; only files under - // a scanned public directory are fair game. Compare canonical paths so a - // symlink inside `public/` cannot redirect the operation, and skip roots that - // don't exist yet rather than letting them canonicalise to their parent. - async function assertInsideAssets(path: string, action: string): Promise { - const resolved = resolve(path) - const real = await realpathOfNearestAncestor(resolved) - const realLayerDirs = await Promise.all(layerDirs.filter(dir => existsSync(dir)).map(dir => fsp.realpath(dir))) - if (!realLayerDirs.some(dir => real === dir || real.startsWith(`${dir}/`))) - throw new Error(`[Nuxt DevTools] File ${path} is not allowed to ${action}, it's outside of the public directory`) - return resolved - } - - const refreshDebounced = debounce(() => { - cache = null - refresh('getStaticAssets') - }, 500) - - nuxt.hook('builder:watch', (event, key) => { - key = relative(nuxt.options.srcDir, resolve(nuxt.options.srcDir, key)) - if (key.startsWith(nuxt.options.dir.public) && (event === 'add' || event === 'unlink')) - refreshDebounced() - }) - - async function scan() { - if (cache) - return cache - - const baseURL = nuxt.options.app.baseURL - const dirs: { layerDir: string, files: string[] }[] = [] - - for (const layerDir of layerDirs) { - const files = await glob(['**/*'], { - cwd: layerDir, - onlyFiles: true, - }) - dirs.push({ layerDir, files }) - } - - const uniquePaths = new Set() - cache = [] - - for (const { layerDir, files } of dirs) { - for (const path of files) { - const filePath = resolve(layerDir, path) - if (!filePath.startsWith(layerDir)) - continue - const stat = await fsp.lstat(filePath) - const fullPath = join(baseURL, path) - - // Check if path already exists in uniquePaths set - if (!uniquePaths.has(fullPath)) { - cache.push({ - path, - publicPath: fullPath, - filePath, - type: guessType(path), - size: stat.size, - mtime: stat.mtimeMs, - layer: publicDir !== layerDir ? layerDir : undefined, - }) - uniquePaths.add(fullPath) - } - } - } - - return cache.sort((a, b) => a.path.localeCompare(b.path)) - } - - return { - async getStaticAssets() { - return await scan() - }, - async getImageMeta(filepath: string) { - filepath = await assertInsideAssets(filepath, 'read') - if (_imageMetaCache.has(filepath)) - return _imageMetaCache.get(filepath) - try { - const meta = imageMeta(await fsp.readFile(filepath)) as ImageMeta - _imageMetaCache.set(filepath, meta) - return meta - } - catch (e) { - _imageMetaCache.set(filepath, undefined) - console.error(e) - return undefined - } - }, - async getTextAssetContent(filepath: string, limit = 300) { - filepath = await assertInsideAssets(filepath, 'read') - try { - const content = await fsp.readFile(filepath, 'utf-8') - return content.slice(0, Math.min(limit, MAX_TEXT_PREVIEW)) - } - catch (e) { - console.error(e) - return undefined - } - }, - async writeStaticAssets(files: AssetEntry[], folder: string) { - // Strip any leading slashes so an absolute-looking `folder`/`path` is - // always treated as relative, then verify the resolved target is still - // contained — resolving against a string-concatenated, unchecked base - // (the previous approach) let `folder` escape the public directory - // before the containment check ever ran. - const baseDir = resolve(publicDir, folder.replace(/^[/\\]+/, '')) - if (baseDir !== publicDir && !baseDir.startsWith(`${publicDir}/`)) - throw new Error(`[Nuxt DevTools] Folder ${folder} is not allowed to upload, it's outside of the public directory`) - - // Canonicalize once so the per-file realpath check below is comparing - // against the same resolved boundary (publicDir itself may sit behind - // a symlink, e.g. macOS's /tmp -> /private/tmp). - const realPublicDir = await realpathOfNearestAncestor(publicDir) - - return await Promise.all( - files.map(async ({ path, content, encoding, override }) => { - let finalPath = resolve(baseDir, path.replace(/^[/\\]+/, '')) - if (finalPath !== baseDir && !finalPath.startsWith(`${baseDir}/`)) - throw new Error(`[Nuxt DevTools] File ${path} is not allowed to upload, it's outside of the public directory`) - - const { ext } = parse(finalPath) - if (extensions !== '*') { - if (!extensions.includes(ext.toLowerCase().slice(1))) - throw new Error(`[Nuxt DevTools] File extension ${ext} is not allowed to upload, allowed extensions are: ${extensions.join(', ')}\nYou can configure it in Nuxt config at \`devtools.assets.uploadExtensions\`.`) - } - - // Lexical containment doesn't stop `fsp.writeFile` from following a - // symlink out of the public directory: canonicalize the nearest - // existing ancestor and reject if it (or an existing target itself) - // escapes. - const realParentDir = await realpathOfNearestAncestor(dirname(finalPath)) - if (realParentDir !== realPublicDir && !realParentDir.startsWith(`${realPublicDir}/`)) - throw new Error(`[Nuxt DevTools] File ${path} is not allowed to upload, it's outside of the public directory`) - const targetStat = await fsp.lstat(finalPath).catch(() => undefined) - if (targetStat?.isSymbolicLink()) - throw new Error(`[Nuxt DevTools] File ${path} is not allowed to upload, it's a symbolic link`) - - if (!override) - finalPath = await collisionFreePath(finalPath) - await fsp.writeFile(finalPath, content, { - encoding: encoding ?? 'utf-8', - }) - return finalPath - }), - ) - }, - async deleteStaticAsset(path: string) { - return await fsp.unlink(await assertInsideAssets(path, 'delete')) - }, - async renameStaticAsset(oldPath: string, newPath: string) { - oldPath = await assertInsideAssets(oldPath, 'rename') - newPath = await assertInsideAssets(newPath, 'rename to') - const exist = cache?.find(asset => asset.filePath === newPath) - if (exist) - throw new Error(`[Nuxt DevTools] File ${newPath} already exists, failed to rename`) - return await fsp.rename(oldPath, newPath) - }, - } satisfies Partial -} - -/** `logo.png` → `logo-1.png` (then `-2`, …) while the target exists. */ -export async function collisionFreePath(path: string, exists = (p: string) => fsp.access(p).then(() => true, () => false)): Promise { - if (!await exists(path)) - return path - const ext = extname(path) - const stem = path.slice(0, path.length - ext.length) - let i = 1 - while (await exists(`${stem}-${i}${ext}`)) - i++ - return `${stem}-${i}${ext}` -} - -/** - * Resolve the real (symlink-free) path of `dir`, or of its nearest existing - * ancestor if `dir` itself doesn't exist yet — so callers can still verify - * containment before creating a new file/folder there. - */ -async function realpathOfNearestAncestor(dir: string): Promise { - try { - return await fsp.realpath(dir) - } - catch { - const parent = dirname(dir) - if (parent === dir) - return dir - return realpathOfNearestAncestor(parent) - } -} - -const reImage = /\.(?:png|jpe?g|jxl|gif|svg|webp|avif|ico|bmp|tiff?)$/i -const reVideo = /\.(?:mp4|webm|ogv|mov|avi|flv|wmv|mpg|mpeg|mkv|3gp|3g2|ts|mts|m2ts|vob|ogm|ogx|rm|rmvb|asf|amv|divx|m4v|svi|viv|f4v|f4p|f4a|f4b)$/i -const reAudio = /\.(?:mp3|wav|ogg|flac|aac|wma|alac|ape|ac3|dts|tta|opus|amr|aiff|au|mid|midi|ra|rm|wv|weba|dss|spx|vox|tak|dsf|dff|dsd|cda)$/i -const reFont = /\.(?:woff2?|eot|ttf|otf|ttc|pfa|pfb|pfm|afm)/i -const reText = /\.(?:json[5c]?|te?xt|[mc]?[jt]sx?|md[cx]?|markdown)/i - -function guessType(path: string): AssetType { - if (reImage.test(path)) - return 'image' - if (reVideo.test(path)) - return 'video' - if (reAudio.test(path)) - return 'audio' - if (reFont.test(path)) - return 'font' - if (reText.test(path)) - return 'text' - return 'other' -} diff --git a/packages/devtools/src/server-rpc/general.ts b/packages/devtools/src/server-rpc/general.ts index 98057cd45d..ee51182ea2 100644 --- a/packages/devtools/src/server-rpc/general.ts +++ b/packages/devtools/src/server-rpc/general.ts @@ -1,6 +1,6 @@ import type { ModuleOptions, NuxtLayout } from '@nuxt/schema' import type { Component, NuxtApp, NuxtOptions, NuxtPage } from 'nuxt/schema' -import type { Import, Unimport } from 'unimport' +import type { Unimport } from 'unimport' import type { AutoImportsWithMetadata, HookInfo, NuxtDevtoolsServerContext, ServerDebugContext, ServerFunctions } from '../types' import { existsSync } from 'node:fs' import fs from 'node:fs/promises' @@ -8,8 +8,6 @@ import { logger } from '@nuxt/kit' import destr from 'destr' import { dirname, join, resolve } from 'pathe' import { snakeCase } from 'scule' - -import { resolveBuiltinPresets } from 'unimport' import { setupHooksDebug } from '../runtime/shared/hooks' import { toJsLiteral } from '../utils/serialize-js-literal' import { getOptions } from './options' @@ -26,8 +24,6 @@ export function setupGeneralRPC({ openInEditorHooks, }: NuxtDevtoolsServerContext) { const components: Component[] = [] - const imports: Import[] = [] - const importPresets: Import[] = [] let importDirs: string[] = [] const serverPages: NuxtPage[] = [] let serverApp: NuxtApp | undefined @@ -44,9 +40,7 @@ export function setupGeneralRPC({ components.sort((a, b) => a.pascalName.localeCompare(b.pascalName)) refresh('getComponents') }) - nuxt.hook('imports:extend', (v) => { - imports.length = 0 - imports.push(...v) + nuxt.hook('imports:extend', () => { refresh('getAutoImports') }) nuxt.hook('pages:extend', (v) => { @@ -67,12 +61,6 @@ export function setupGeneralRPC({ nuxt.hook('app:resolve', (app) => { serverApp = app }) - nuxt.hook('imports:sources', async (v) => { - const result = (await resolveBuiltinPresets(v)).flat() - importPresets.length = 0 - importPresets.push(...result) - refresh('getAutoImports') - }) nuxt.hook('imports:context', (_unimport: any) => { unimport = _unimport as Unimport }) @@ -173,12 +161,10 @@ export function setupGeneralRPC({ getServerPages(): NuxtPage[] { return serverPages }, - getAutoImports(): AutoImportsWithMetadata { + async getAutoImports(): Promise { return { - imports: [ - ...imports, - ...importPresets, - ], + // presets, config `imports` and scanned dirs, as Nuxt resolves them + imports: await unimport?.getImports() ?? [], metadata: unimport?.getMetadata(), dirs: importDirs, } diff --git a/packages/devtools/src/server-rpc/index.ts b/packages/devtools/src/server-rpc/index.ts index 6022504fca..4a4522b129 100644 --- a/packages/devtools/src/server-rpc/index.ts +++ b/packages/devtools/src/server-rpc/index.ts @@ -7,7 +7,6 @@ import { logger } from '@nuxt/kit' import { colors } from 'consola/utils' import { RPC_NAMESPACE } from '../rpc-namespace' import { setupAnalyzeBuildRPC } from './analyze-build' -import { setupAssetsRPC } from './assets' import { setupCustomTabRPC } from './custom-tabs' import { setupGeneralRPC } from './general' import { createNotifier, setupMessagesRPC } from './messages' @@ -148,7 +147,6 @@ export function setupRPC(nuxt: Nuxt, options: ModuleOptions) { ...setupMessagesRPC(ctx), ...setupCustomTabRPC(ctx), ...setupStorageRPC(ctx), - ...setupAssetsRPC(ctx), ...setupNpmRPC(ctx), // Bridge the `devtools:terminal:*` hooks onto the Vite DevTools terminals // host so module terminals surface in the built-in Terminals dock. Also diff --git a/packages/devtools/src/server-rpc/storage-watch.ts b/packages/devtools/src/server-rpc/storage-watch.ts index fb06c1183e..aab491ab7f 100644 --- a/packages/devtools/src/server-rpc/storage-watch.ts +++ b/packages/devtools/src/server-rpc/storage-watch.ts @@ -1,11 +1,11 @@ import type { Storage } from 'unstorage' -import { normalizeBaseKey, normalizeKey } from 'unstorage' export type UnwatchStorageMount = () => Promise | void type WatchEvent = 'update' | 'remove' type WatchCallback = (event: WatchEvent, key: string) => void export async function watchStorageMount(storage: Storage, mountName: string, onChange: WatchCallback): Promise { + const { normalizeBaseKey, normalizeKey } = await import('unstorage') const mountKey = normalizeBaseKey(mountName) const mount = storage.getMount(mountKey) if (!mount || normalizeBaseKey(mount.base) !== mountKey || !mount.driver?.watch) diff --git a/packages/devtools/src/server-rpc/storage.ts b/packages/devtools/src/server-rpc/storage.ts index 471849685b..b8dca286ac 100644 --- a/packages/devtools/src/server-rpc/storage.ts +++ b/packages/devtools/src/server-rpc/storage.ts @@ -1,25 +1,35 @@ import type { Storage, StorageValue } from 'unstorage' import type { NuxtDevtoolsServerContext, ServerFunctions } from '../types' import type { AnyNitro, AnyStorageMounts } from '../utils/nitro-compat' -import { builtinDrivers, createStorage, normalizeKey } from 'unstorage' import { watchStorageMount } from './storage-watch' // Mounts backed by the project itself (`root`/`src` are the filesystem) stay // off limits for listing and for every item operation alike. Normalise first: // unstorage routes `/root:x`, `:root:x` and `root/x` to the `root` mount too. const IGNORE_STORAGE_MOUNTS = ['root', 'build', 'src', 'cache'] -function shouldIgnoreStorageKey(key: string) { - return IGNORE_STORAGE_MOUNTS.includes(normalizeKey(key).split(':')[0]!) -} export function setupStorageRPC(ctx: NuxtDevtoolsServerContext) { const { nuxt } = ctx const storageMounts: AnyStorageMounts = {} + let unstorage: typeof import('unstorage') | undefined let storage: Storage | undefined let unwatchStorageMounts: Array<() => Promise | void> = [] + function shouldIgnoreStorageKey(key: string) { + return !unstorage || IGNORE_STORAGE_MOUNTS.includes(unstorage.normalizeKey(key).split(':')[0]!) + } + nuxt.hook('nitro:init', async (nitro: AnyNitro) => { + // `unstorage` is a relaxed optional peer: use the copy the project's Nitro + // brings (v1 with `nitropack`, v2 with `nitro`) instead of shipping our own. + const loaded = await import('unstorage').catch((err) => { + nitro.logger.warn('Failed to load `unstorage`, the DevTools storage browser is disabled:', err) + }) + if (!loaded) + return + unstorage = loaded + // Taken from https://github.com/unjs/nitro/blob/d83f2b65165d7ba996e7ef129ea99ff5b551dccc/src/storage.ts#L7-L10 // Waiting for https://github.com/unjs/unstorage/issues/53 const mounts: AnyStorageMounts = { @@ -37,14 +47,14 @@ export function setupStorageRPC(ctx: NuxtDevtoolsServerContext) { // whichever one the running server uses — in-memory-driver mounts (e.g. // the default cache) won't reflect the live server's in-process state, // only mounts backed by external state (filesystem, redis, ...) will. - const nextStorage = createStorage() + const nextStorage = unstorage.createStorage() for (const [path, opts] of Object.entries(mounts)) { if (!opts?.driver) { nitro.logger.warn(`No \`driver\` set for storage mount point "${path}".`) continue } try { - const driverImport = builtinDrivers[opts.driver as keyof typeof builtinDrivers] || opts.driver + const driverImport = unstorage.builtinDrivers[opts.driver as keyof typeof unstorage.builtinDrivers] || opts.driver const driverFactory = await import(driverImport).then(r => r.default || r) nextStorage.mount(path, driverFactory(opts)) } diff --git a/packages/devtools/test/assets-rpc.test.ts b/packages/devtools/test/assets-rpc.test.ts deleted file mode 100644 index 02d39949e1..0000000000 --- a/packages/devtools/test/assets-rpc.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type { NuxtDevtoolsServerContext } from '../src/types' -import fsp from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { createHooks } from 'hookable' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { collisionFreePath, setupAssetsRPC } from '../src/server-rpc/assets' - -let root: string -let publicDir: string -let rpc: ReturnType - -beforeEach(async () => { - root = await fsp.mkdtemp(join(tmpdir(), 'nuxt-devtools-assets-')) - publicDir = join(root, 'public') - await fsp.mkdir(publicDir) - await fsp.writeFile(join(publicDir, 'notes.txt'), 'x'.repeat(20_000)) - await fsp.writeFile(join(root, 'secret.txt'), 'top secret') - - const hooks = createHooks() - rpc = setupAssetsRPC({ - nuxt: { - hook: hooks.hook.bind(hooks), - options: { srcDir: root, dir: { public: 'public' }, _layers: [], app: { baseURL: '/' } }, - }, - refresh: () => {}, - options: {}, - } as unknown as NuxtDevtoolsServerContext) -}) - -afterEach(() => fsp.rm(root, { recursive: true, force: true })) - -describe('assets RPC path containment', () => { - it('refuses to read, delete or rename anything outside the public directories', async () => { - const secret = join(root, 'secret.txt') - const sibling = join(root, 'publicX', 'a.txt') - - await expect(rpc.getTextAssetContent(secret)).rejects.toThrow(/outside of the public directory/) - await expect(rpc.getImageMeta(secret)).rejects.toThrow(/outside of the public directory/) - await expect(rpc.getTextAssetContent(join(publicDir, '..', 'secret.txt'))).rejects.toThrow() - await expect(rpc.getTextAssetContent(sibling)).rejects.toThrow() - await expect(rpc.deleteStaticAsset(secret)).rejects.toThrow(/outside of the public directory/) - await expect(rpc.renameStaticAsset(join(publicDir, 'notes.txt'), secret)).rejects.toThrow(/outside of the public directory/) - - expect(await fsp.readFile(secret, 'utf-8')).toBe('top secret') - }) - - it('does not follow symlinks out of the public directory', async () => { - await fsp.symlink(join(root, 'secret.txt'), join(publicDir, 'linked.txt')) - await fsp.symlink(root, join(publicDir, 'linked-dir')) - - await expect(rpc.getTextAssetContent(join(publicDir, 'linked.txt'))).rejects.toThrow(/outside of the public directory/) - await expect(rpc.getTextAssetContent(join(publicDir, 'linked-dir', 'secret.txt'))).rejects.toThrow(/outside of the public directory/) - await expect(rpc.deleteStaticAsset(join(publicDir, 'linked-dir', 'secret.txt'))).rejects.toThrow(/outside of the public directory/) - - expect(await fsp.readFile(join(root, 'secret.txt'), 'utf-8')).toBe('top secret') - }) - - it('serves files inside public and caps the text preview', async () => { - const content = await rpc.getTextAssetContent(join(publicDir, 'notes.txt'), 1_000_000) - expect(content).toHaveLength(10_000) - }) -}) - -describe('collisionFreePath', () => { - const existing = (present: string[]) => async (p: string) => present.includes(p) - - it('keeps the extension intact when suffixing', async () => { - expect(await collisionFreePath('/p/logo.png', existing(['/p/logo.png']))).toBe('/p/logo-1.png') - expect(await collisionFreePath('/p/logo.png', existing(['/p/logo.png', '/p/logo-1.png']))).toBe('/p/logo-2.png') - expect(await collisionFreePath('/p/LICENSE', existing(['/p/LICENSE']))).toBe('/p/LICENSE-1') - expect(await collisionFreePath('/p/logo.png', existing([]))).toBe('/p/logo.png') - }) -}) diff --git a/packages/devtools/test/write-static-assets.test.ts b/packages/devtools/test/write-static-assets.test.ts deleted file mode 100644 index 6d6e1662e7..0000000000 --- a/packages/devtools/test/write-static-assets.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import type { Nuxt } from 'nuxt/schema' -import type { NuxtDevtoolsServerContext } from '../src/types' -import fsp from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { setupAssetsRPC } from '../src/server-rpc/assets' - -function fakeContext(srcDir: string): NuxtDevtoolsServerContext { - return { - nuxt: { - options: { - srcDir, - dir: { public: 'public' }, - app: { baseURL: '/' }, - _layers: [], - }, - hook: () => {}, - } as unknown as Nuxt, - options: {}, - refresh: () => {}, - } as unknown as NuxtDevtoolsServerContext -} - -describe('writeStaticAssets', () => { - let root: string - - beforeEach(async () => { - root = await fsp.mkdtemp(join(tmpdir(), 'devtools-assets-')) - await fsp.mkdir(join(root, 'public'), { recursive: true }) - }) - - afterEach(async () => { - await fsp.rm(root, { recursive: true, force: true }) - }) - - it('writes files inside the public directory', async () => { - const { writeStaticAssets } = setupAssetsRPC(fakeContext(root)) - const [written] = await writeStaticAssets!([{ path: 'a.txt', content: 'hi' }], '') - expect(written).toBe(join(root, 'public', 'a.txt')) - expect(await fsp.readFile(written!, 'utf-8')).toBe('hi') - }) - - it('rejects a folder that escapes the public directory', async () => { - const { writeStaticAssets } = setupAssetsRPC(fakeContext(root)) - await expect( - writeStaticAssets!([{ path: 'nuxt.config.ts', content: 'evil' }], '/../..'), - ).rejects.toThrow(/outside of the public directory/) - }) - - it('rejects a file path that escapes the public directory', async () => { - const { writeStaticAssets } = setupAssetsRPC(fakeContext(root)) - await expect( - writeStaticAssets!([{ path: '../../etc/passwd', content: 'evil' }], ''), - ).rejects.toThrow(/outside of the public directory/) - }) - - it('treats an absolute-looking path as relative to the public directory', async () => { - const { writeStaticAssets } = setupAssetsRPC(fakeContext(root)) - const [written] = await writeStaticAssets!([{ path: '/passwd.txt', content: 'not the real one' }], '') - expect(written).toBe(join(root, 'public', 'passwd.txt')) - }) - - it('rejects writing through a symlinked directory that points outside the public directory', async () => { - const outside = join(root, 'outside') - await fsp.mkdir(outside, { recursive: true }) - await fsp.symlink(outside, join(root, 'public', 'linked'), 'dir') - - const { writeStaticAssets } = setupAssetsRPC(fakeContext(root)) - await expect( - writeStaticAssets!([{ path: 'evil.txt', content: 'evil' }], '/linked'), - ).rejects.toThrow(/outside of the public directory/) - await expect(fsp.access(join(outside, 'evil.txt'))).rejects.toThrow() - }) - - it('rejects overwriting an existing symlink target', async () => { - const outsideFile = join(root, 'secret.txt') - await fsp.writeFile(outsideFile, 'original') - await fsp.symlink(outsideFile, join(root, 'public', 'link.txt')) - - const { writeStaticAssets } = setupAssetsRPC(fakeContext(root)) - await expect( - writeStaticAssets!([{ path: 'link.txt', content: 'evil', override: true }], ''), - ).rejects.toThrow(/symbolic link/) - expect(await fsp.readFile(outsideFile, 'utf-8')).toBe('original') - }) -}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eca0b18c69..b414f8a558 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -415,9 +415,6 @@ catalogs: fast-npm-meta: specifier: ^2.2.0 version: 2.2.0 - image-meta: - specifier: ^0.3.0 - version: 0.3.0 local-pkg: specifier: ^1.2.1 version: 1.2.1 @@ -448,9 +445,6 @@ catalogs: unplugin: specifier: ^3.4.0 version: 3.4.0 - unstorage: - specifier: ^1.17.5 - version: 1.17.5 verkit: specifier: ^0.5.0 version: 0.5.0 @@ -470,6 +464,9 @@ catalogs: '@types/node': specifier: ^26.6.4 version: 26.6.4 + unstorage: + specifier: ^1.17.5 + version: 1.17.5 overrides: '@devframes/hub': ^1.2.3 @@ -673,9 +670,6 @@ importers: hookable: specifier: catalog:frontend version: 6.1.2 - image-meta: - specifier: catalog:prod - version: 0.3.0 local-pkg: specifier: catalog:prod version: 1.2.1 @@ -694,18 +688,12 @@ importers: perfect-debounce: specifier: catalog:frontend version: 2.1.0 - pkg-types: - specifier: catalog:prod - version: 2.3.3 tinyglobby: specifier: catalog:prod version: 0.2.17 unplugin: specifier: catalog:prod version: 3.4.0(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.3)(vite@8.3.4)(webpack@5.111.1(cssnano@7.1.9(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.28)(svgo@4.1.0)) - unstorage: - specifier: catalog:prod - version: 1.17.5(db0@0.3.4)(ioredis@5.11.1(supports-color@10.2.2)) verkit: specifier: catalog:prod version: 0.5.0 @@ -848,6 +836,9 @@ importers: unplugin-vue-markdown: specifier: catalog:buildtools version: 32.1.1(esbuild@0.28.2)(rolldown@1.2.12)(rollup@4.64.3)(vite@8.3.4)(webpack@5.111.1(cssnano@7.1.9(postcss@8.5.28))(csso@5.0.5)(esbuild@0.28.2)(lightningcss@1.33.0)(postcss@8.5.28)(svgo@4.1.0)) + unstorage: + specifier: catalog:types + version: 1.17.5(db0@0.3.4)(ioredis@5.11.1(supports-color@10.2.2)) vanilla-jsoneditor: specifier: catalog:frontend version: 3.13.0(@typescript-eslint/types@8.71.0) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2c084e973e..c32a69c6ad 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -252,7 +252,6 @@ catalogs: devframe: *devframe escape-string-regexp: ^5.0.0 fast-npm-meta: ^2.2.0 - image-meta: ^0.3.0 local-pkg: ^1.2.1 magic-string: ^1.4.3 magicast: ^0.5.5 @@ -263,7 +262,6 @@ catalogs: tinyexec: ^1.3.1 tinyglobby: ^0.2.17 unplugin: ^3.4.0 - unstorage: ^1.17.5 verkit: ^0.5.0 vite-plugin-inspect: ^12.0.2 vite-plugin-vue-devtools: ^9.0.0-beta.1 @@ -274,3 +272,4 @@ catalogs: '@types/node': ^26.6.4 '@vitejs/devtools-kit': *vite-devtools unimport: ^7.0.2 + unstorage: ^1.17.5 From 198be8201323daa5c46638e15ee8ac63ec12b817 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Fri, 9 Oct 2026 07:39:36 +0000 Subject: [PATCH 2/3] fix(client): pass the custom tab action index as $event to satisfy vue-tsc --- packages/devtools/client/pages/modules/custom-[name].vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/devtools/client/pages/modules/custom-[name].vue b/packages/devtools/client/pages/modules/custom-[name].vue index 73bedef4e5..e2a1604703 100644 --- a/packages/devtools/client/pages/modules/custom-[name].vue +++ b/packages/devtools/client/pages/modules/custom-[name].vue @@ -61,7 +61,7 @@ onMounted(() => { :title="tab.view.title || tab.title" :description="tab.view.description" :actions="tab.view.actions" - @action="idx => rpc.customTabAction(tab!.name, idx)" + @action="rpc.customTabAction(tab!.name, $event)" />