diff --git a/README.md b/README.md index ad07f5641..b728748fd 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,9 @@ pnpm dev:prepare # Develop with the playground pnpm dev +# Playground only +pnpm dev:playground + # Build the playground pnpm dev:build diff --git a/build.config.ts b/build.config.ts index e7aa418a3..dffa0077e 100644 --- a/build.config.ts +++ b/build.config.ts @@ -8,7 +8,10 @@ export default defineBuildConfig({ 'drizzle-orm', 'drizzle-kit', './db/lib', - 'db/lib' + 'db/lib', + 'sirv', + './devtools/setup', + 'devtools/setup' ], entries: [ 'src/module', @@ -50,6 +53,20 @@ export default defineBuildConfig({ input: 'src/cache/runtime/', outDir: 'dist/cache/runtime', builder: 'mkdist' + }, + // DevTools + { + input: 'src/devtools/', + outDir: 'dist/devtools', + builder: 'mkdist', + // Only ship the Nuxt DevTools integration code. + // Avoid accidentally bundling local/generated UI artifacts (e.g. `src/devtools/client/.nuxt`). + pattern: ['*.ts'] + }, + { + input: 'src/devtools-runtime/', + outDir: 'dist/devtools-runtime', + builder: 'mkdist' } ] }) diff --git a/docs/content/docs/1.getting-started/1.index.md b/docs/content/docs/1.getting-started/1.index.md index 11cd18c23..f851ff978 100644 --- a/docs/content/docs/1.getting-started/1.index.md +++ b/docs/content/docs/1.getting-started/1.index.md @@ -37,15 +37,43 @@ NuxtHub provides optional features to help you build full-stack applications: NuxtHub also integrates with the [Nuxt DevTools](https://devtools.nuxt.com/) to provide a complete development experience. ::tabs - :::tabs-item{label="Database"} + :::div{label="Database"} :img{alt="Nuxt DevTools Database" height="515" src="/images/landing/nuxt-devtools-database.png" width="915"} ::: - + ::: +:: + +By default, the KV/Blob/Cache DevTools UI is loaded from `https://hub.nuxt.com` and embedded as an iframe. This avoids shipping a separate UI bundle with the module, but it requires an internet connection. + +::note +Safari is currently not supported when the hosted UI (`https://...`) calls your local dev server (`http://localhost...`) due to mixed content restrictions. See [nuxt-hub/core#34 (comment)](https://github.com/nuxt-hub/core/issues/34#issuecomment-2027021483). :: + +If you run your dev server on a non-default port and the UI points to the wrong origin, you can override the local origin passed to the hosted UI: + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + hub: { + devtools: { + appOrigin: 'http://localhost:3010' + } + } +}) +``` + +To disable storage tabs in Nuxt DevTools: + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + hub: { + devtools: false + } +}) +``` diff --git a/docs/content/docs/3.blob/1.index.md b/docs/content/docs/3.blob/1.index.md index 9f9f3ef53..85f03eade 100644 --- a/docs/content/docs/3.blob/1.index.md +++ b/docs/content/docs/3.blob/1.index.md @@ -4,6 +4,10 @@ description: Setup Blob Storage in your Nuxt application to store assets like im navigation.title: Setup --- +::note +In development, you can inspect and manage blobs from Nuxt DevTools (NuxtHub Blob tab). The UI is hosted on `https://hub.nuxt.com` and embedded as an iframe. +:: + ## Getting Started ::steps{level="3"} diff --git a/docs/content/docs/4.kv/1.index.md b/docs/content/docs/4.kv/1.index.md index ed62c8644..1a2e5b0a1 100644 --- a/docs/content/docs/4.kv/1.index.md +++ b/docs/content/docs/4.kv/1.index.md @@ -5,6 +5,10 @@ description: Set up Key-Value Storage in your Nuxt application, including instal NuxtHub Key Value Storage automatically configures [Nitro Storage](https://nitro.build/guide/storage), which is built on [unstorage](https://unstorage.unjs.io/). +::note +In development, you can inspect and manage KV from Nuxt DevTools (NuxtHub KV tab). The UI is hosted on `https://hub.nuxt.com` and embedded as an iframe. +:: + ## Getting Started Enable the key-value storage in your NuxtHub project by adding the `kv` property to the `hub` object in your `nuxt.config.ts` file. diff --git a/docs/content/docs/5.cache/1.index.md b/docs/content/docs/5.cache/1.index.md index 4bad8ea32..c42b81025 100644 --- a/docs/content/docs/5.cache/1.index.md +++ b/docs/content/docs/5.cache/1.index.md @@ -5,6 +5,10 @@ description: Configure caching in your Nuxt application, including setup for pag NuxtHub Cache automatically configures [Nitro's cache storage](https://nitro.build/guide/cache#customize-cache-storage). It allows you to cache API routes, server functions, and pages in your application. +::note +In development, you can inspect and manage cache entries from Nuxt DevTools (NuxtHub Cache tab). The UI is hosted on `https://hub.nuxt.com` and embedded as an iframe. +:: + ## Getting Started Enable cache storage in your project by setting `cache: true` in the NuxtHub config. diff --git a/docs/public/images/landing/nuxt-devtools-blob.png b/docs/public/images/landing/nuxt-devtools-blob.png new file mode 100644 index 000000000..ed4d61a11 Binary files /dev/null and b/docs/public/images/landing/nuxt-devtools-blob.png differ diff --git a/eslint.config.js b/eslint.config.js index e71506107..c42099256 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -16,6 +16,13 @@ export default createConfigForNuxt({ './docs' ] } +}).append({ + ignores: [ + '**/.nuxt/**', + '**/.output/**', + '**/dist/**', + '**/.wrangler/**' + ] }).overrideRules({ '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-unused-vars': ['error', { diff --git a/package.json b/package.json index d5e195402..d180918e6 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,8 @@ "scripts": { "prepack": "nuxt-module-build build", "dev:prepare": "nuxt-module-build build --stub && mkdist --src=src/blob/lib --dist=dist/blob/lib -d && mkdist --src=src/db/lib --dist=dist/db/lib -d && nuxt-module-build prepare && nuxi prepare playground", - "dev": "nuxi dev playground", + "dev": "pnpm dev:playground", + "dev:playground": "nuxi dev playground", "dev:build": "nuxi build playground", "docs": "PORT=4000 nuxi dev docs", "docs:build": "nuxi generate docs", diff --git a/src/devtools-runtime/server/api/_hub/blob/[...pathname].ts b/src/devtools-runtime/server/api/_hub/blob/[...pathname].ts new file mode 100644 index 000000000..62f808ba3 --- /dev/null +++ b/src/devtools-runtime/server/api/_hub/blob/[...pathname].ts @@ -0,0 +1,42 @@ +import { eventHandler, createError, getRequestURL, readFormData } from 'h3' +import { blob } from 'hub:blob' +import { handleCors } from '../../../utils/cors' + +function safeDecodeURIComponent(input: string) { + try { + return decodeURIComponent(input) + } catch { + return input + } +} + +export default eventHandler(async (event) => { + if (handleCors(event)) return + const method = event.method + const baseRoute = '/api/_hub/blob/' + const urlPath = getRequestURL(event).pathname + const raw = urlPath.startsWith(baseRoute) ? urlPath.slice(baseRoute.length) : '' + const pathname = safeDecodeURIComponent(raw.replace(/^\/+/, '')) + if (!pathname) throw createError({ statusCode: 400, message: 'Pathname is required' }) + + if (method === 'GET') { + return blob.serve(event, pathname) + } + + if (method === 'PUT') { + const form = await readFormData(event) + const file = form.get('file') + if (!(file instanceof File)) { + throw createError({ statusCode: 400, message: 'Missing file' }) + } + await blob.put(pathname, file) + return 'OK' + } + + if (method === 'DELETE') { + await blob.del(pathname) + return 'OK' + } + + throw createError({ statusCode: 405, message: 'Method not allowed' }) +}) diff --git a/src/devtools-runtime/server/api/_hub/blob/head.get.ts b/src/devtools-runtime/server/api/_hub/blob/head.get.ts new file mode 100644 index 000000000..90fe006ec --- /dev/null +++ b/src/devtools-runtime/server/api/_hub/blob/head.get.ts @@ -0,0 +1,20 @@ +import { eventHandler, getRequestURL } from 'h3' +import { blob } from 'hub:blob' +import { handleCors } from '../../../utils/cors' + +function safeDecodeURIComponent(input: string) { + try { + return decodeURIComponent(input) + } catch { + return input + } +} + +export default eventHandler(async (event) => { + if (handleCors(event)) return + const baseRoute = '/api/_hub/blob/head/' + const urlPath = getRequestURL(event).pathname + const raw = urlPath.startsWith(baseRoute) ? urlPath.slice(baseRoute.length) : '' + const pathname = safeDecodeURIComponent(raw.replace(/^\/+/, '')) + return blob.head(pathname) +}) diff --git a/src/devtools-runtime/server/api/_hub/blob/index.get.ts b/src/devtools-runtime/server/api/_hub/blob/index.get.ts new file mode 100644 index 000000000..fbfc4f9ce --- /dev/null +++ b/src/devtools-runtime/server/api/_hub/blob/index.get.ts @@ -0,0 +1,14 @@ +import { eventHandler, getQuery } from 'h3' +import { blob } from 'hub:blob' +import { handleCors } from '../../../utils/cors' + +export default eventHandler(async (event) => { + if (handleCors(event)) return + const { prefix = '', limit, cursor, folded } = getQuery(event) + return blob.list({ + prefix: prefix as string, + limit: limit ? Number(limit) : undefined, + cursor: cursor as string, + folded: folded === 'true' + }) +}) diff --git a/src/devtools-runtime/server/api/_hub/blob/index.post.ts b/src/devtools-runtime/server/api/_hub/blob/index.post.ts new file mode 100644 index 000000000..1533c169d --- /dev/null +++ b/src/devtools-runtime/server/api/_hub/blob/index.post.ts @@ -0,0 +1,15 @@ +import { eventHandler, getQuery } from 'h3' +import { destr } from 'destr' +import { blob } from 'hub:blob' +import { handleCors } from '../../../utils/cors' + +export default eventHandler(async (event) => { + if (handleCors(event)) return + const query = getQuery(event) + return blob.handleUpload(event, { + formKey: (query.formKey as string) || 'files', + multiple: query.multiple !== 'false', + put: destr(query.put), + ensure: destr(query.ensure) + }) +}) diff --git a/src/devtools-runtime/server/api/_hub/cache/[...key].ts b/src/devtools-runtime/server/api/_hub/cache/[...key].ts new file mode 100644 index 000000000..c533733e7 --- /dev/null +++ b/src/devtools-runtime/server/api/_hub/cache/[...key].ts @@ -0,0 +1,42 @@ +import { eventHandler, createError, sendNoContent, getRequestURL } from 'h3' +import { useStorage } from 'nitropack/runtime' +import { handleCors } from '../../../utils/cors' + +function safeDecodeURIComponent(input: string) { + try { + return decodeURIComponent(input) + } catch { + return input + } +} + +export default eventHandler(async (event) => { + if (handleCors(event)) return + + const method = event.method + const baseRoute = '/api/_hub/cache/' + const urlPath = getRequestURL(event).pathname + const raw = urlPath.startsWith(baseRoute) ? urlPath.slice(baseRoute.length) : '' + const key = safeDecodeURIComponent(raw.replace(/^\/+/, '')) + if (!key) throw createError({ statusCode: 400, message: 'Key is required' }) + + const cache = useStorage('cache') + + if (method === 'HEAD') { + const value = await cache.getItem(key) + if (value === null || value === undefined) throw createError({ statusCode: 404, message: 'Key not found' }) + return sendNoContent(event) + } + + if (method === 'GET') { + const value = await cache.getItem(key) + return value + } + + if (method === 'DELETE') { + await cache.removeItem(key) + return 'OK' + } + + throw createError({ statusCode: 405, message: 'Method not allowed' }) +}) diff --git a/src/devtools-runtime/server/api/_hub/cache/batch-delete.post.ts b/src/devtools-runtime/server/api/_hub/cache/batch-delete.post.ts new file mode 100644 index 000000000..9c1f5b22f --- /dev/null +++ b/src/devtools-runtime/server/api/_hub/cache/batch-delete.post.ts @@ -0,0 +1,15 @@ +import { eventHandler, readBody, createError } from 'h3' +import { useStorage } from 'nitropack/runtime' +import { handleCors } from '../../../utils/cors' + +export default eventHandler(async (event) => { + if (handleCors(event)) return + const body = await readBody(event) + const keys = body?.keys + if (!Array.isArray(keys) || keys.some(k => typeof k !== 'string')) { + throw createError({ statusCode: 400, message: 'Expected body: { keys: string[] }' }) + } + const cache = useStorage('cache') + await Promise.all(keys.map(k => cache.removeItem(k).catch(() => undefined))) + return { deleted: keys.length } +}) diff --git a/src/devtools-runtime/server/api/_hub/cache/clear.delete.ts b/src/devtools-runtime/server/api/_hub/cache/clear.delete.ts new file mode 100644 index 000000000..e49ebd3df --- /dev/null +++ b/src/devtools-runtime/server/api/_hub/cache/clear.delete.ts @@ -0,0 +1,30 @@ +import { eventHandler, getRequestURL } from 'h3' +import { useStorage } from 'nitropack/runtime' +import { handleCors } from '../../../utils/cors' + +function safeDecodeURIComponent(input: string) { + try { + return decodeURIComponent(input) + } catch { + return input + } +} + +export default eventHandler(async (event) => { + if (handleCors(event)) return + const cache = useStorage('cache') + + const baseRoute = '/api/_hub/cache/clear/' + const urlPath = getRequestURL(event).pathname + const raw = urlPath.startsWith(baseRoute) ? urlPath.slice(baseRoute.length) : '' + const base = safeDecodeURIComponent(raw.replace(/^\/+/, '')) + + if (!base) { + await cache.clear() + return { cleared: true } + } + + const keys = await cache.getKeys(base) + await Promise.all(keys.map(k => cache.removeItem(k).catch(() => undefined))) + return { cleared: true, deleted: keys.length } +}) diff --git a/src/devtools-runtime/server/api/_hub/cache/index.get.ts b/src/devtools-runtime/server/api/_hub/cache/index.get.ts new file mode 100644 index 000000000..8892ba402 --- /dev/null +++ b/src/devtools-runtime/server/api/_hub/cache/index.get.ts @@ -0,0 +1,18 @@ +import { eventHandler, getQuery } from 'h3' +import { useStorage } from 'nitropack/runtime' +import { handleCors } from '../../../utils/cors' + +export default eventHandler(async (event) => { + if (handleCors(event)) return + const { prefix = '' } = getQuery(event) + const cache = useStorage('cache') + const keys = await cache.getKeys(prefix as string) + return Promise.all(keys.map(async (key) => { + try { + const value = await cache.getItem(key) + return { key, value } + } catch { + return { key, value: null } + } + })) +}) diff --git a/src/devtools-runtime/server/api/_hub/kv/[...key].ts b/src/devtools-runtime/server/api/_hub/kv/[...key].ts new file mode 100644 index 000000000..76919b2fe --- /dev/null +++ b/src/devtools-runtime/server/api/_hub/kv/[...key].ts @@ -0,0 +1,66 @@ +import { eventHandler, getHeader, readBody, sendNoContent, createError, getRequestURL } from 'h3' +import { kv } from 'hub:kv' +import { handleCors } from '../../../utils/cors' + +function safeDecodeURIComponent(input: string) { + try { + return decodeURIComponent(input) + } catch { + return input + } +} + +function toStorageKey(pathParam: string) { + // Keep compatibility with unstorage-style prefixes while preventing raw `/` keys + // from becoming filesystem paths in local development drivers. + return safeDecodeURIComponent(pathParam).replace(/\//g, ':') +} + +export default eventHandler(async (event) => { + if (handleCors(event)) return + + const method = event.method + const baseRoute = '/api/_hub/kv/' + const urlPath = getRequestURL(event).pathname + const raw = urlPath.startsWith(baseRoute) ? urlPath.slice(baseRoute.length) : '' + const keyParam = raw.replace(/^\/+/, '') + + // `unstorage/drivers/http` lists keys with a trailing `/:`. + if (method === 'GET' && (keyParam === '' || keyParam === ':' || keyParam.endsWith('/:'))) { + const base = (keyParam === '' || keyParam === ':') + ? '' + : keyParam.slice(0, -2) // remove `/:` + const prefix = toStorageKey(base) + return kv.keys(prefix) + } + + const key = toStorageKey(keyParam) + if (!key) throw createError({ statusCode: 400, message: 'Key is required' }) + + if (method === 'HEAD') { + const value = await kv.get(key) + if (value === null || value === undefined) throw createError({ statusCode: 404, message: 'Key not found' }) + return sendNoContent(event) + } + + if (method === 'GET') { + const value = await kv.get(key) + if (value === null || value === undefined) throw createError({ statusCode: 404, message: 'Key not found' }) + return value + } + + if (method === 'PUT') { + const ttlHeader = getHeader(event, 'x-ttl') + const ttl = ttlHeader ? Number.parseInt(ttlHeader, 10) : undefined + const body = await readBody(event) + await kv.set(key, body, ttl ? { ttl } : undefined) + return 'OK' + } + + if (method === 'DELETE') { + await kv.del(key) + return 'OK' + } + + throw createError({ statusCode: 405, message: 'Method not allowed' }) +}) diff --git a/src/devtools-runtime/server/api/blob/[...pathname].ts b/src/devtools-runtime/server/api/blob/[...pathname].ts new file mode 100644 index 000000000..b2279024d --- /dev/null +++ b/src/devtools-runtime/server/api/blob/[...pathname].ts @@ -0,0 +1,49 @@ +import { eventHandler, getQuery, createError, getRequestURL, readFormData } from 'h3' +import { blob } from 'hub:blob' +import { handleCors } from '../../utils/cors' + +function safeDecodeURIComponent(input: string) { + try { + return decodeURIComponent(input) + } catch { + return input + } +} + +export default eventHandler(async (event) => { + if (handleCors(event)) return + const method = event.method + // Nitro's `**:pathname` router param can be unreliable for deep paths; derive from URL. + const base = '/_hub/devtools/blob/' + const urlPath = getRequestURL(event).pathname + const raw = urlPath.startsWith(base) ? urlPath.slice(base.length) : '' + const pathname = safeDecodeURIComponent(raw.replace(/^\/+/, '')) + if (!pathname) throw createError({ statusCode: 400, message: 'Pathname is required' }) + + if (method === 'GET') { + const meta = getQuery(event).meta === 'true' + if (meta) { + const head = await blob.head(pathname) + if (!head) throw createError({ statusCode: 404, message: 'Blob not found' }) + return head + } + return blob.serve(event, pathname) + } + + if (method === 'PUT') { + const form = await readFormData(event) + const file = form.get('file') + if (!(file instanceof File)) { + throw createError({ statusCode: 400, message: 'Missing file' }) + } + const object = await blob.put(pathname, file) + return [object] + } + + if (method === 'DELETE') { + await blob.del(pathname) + return { pathname, deleted: true } + } + + throw createError({ statusCode: 405, message: 'Method not allowed' }) +}) diff --git a/src/devtools-runtime/server/api/blob/index.get.ts b/src/devtools-runtime/server/api/blob/index.get.ts new file mode 100644 index 000000000..d88a07054 --- /dev/null +++ b/src/devtools-runtime/server/api/blob/index.get.ts @@ -0,0 +1,14 @@ +import { eventHandler, getQuery } from 'h3' +import { blob } from 'hub:blob' +import { handleCors } from '../../utils/cors' + +export default eventHandler(async (event) => { + if (handleCors(event)) return + const { prefix = '', limit, cursor, folded } = getQuery(event) + return blob.list({ + prefix: prefix as string, + limit: limit ? Number(limit) : undefined, + cursor: cursor as string, + folded: folded === 'true' + }) +}) diff --git a/src/devtools-runtime/server/api/cache/[key].ts b/src/devtools-runtime/server/api/cache/[key].ts new file mode 100644 index 000000000..448693f18 --- /dev/null +++ b/src/devtools-runtime/server/api/cache/[key].ts @@ -0,0 +1,23 @@ +import { eventHandler, getRouterParam, createError } from 'h3' +import { useStorage } from 'nitropack/runtime' +import { handleCors } from '../../utils/cors' + +export default eventHandler(async (event) => { + if (handleCors(event)) return + const method = event.method + const key = decodeURIComponent(getRouterParam(event, 'key') || '') + if (!key) throw createError({ statusCode: 400, message: 'Key is required' }) + const cache = useStorage('cache') + + if (method === 'GET') { + const value = await cache.getItem(key) + return { key, value } + } + + if (method === 'DELETE') { + await cache.removeItem(key) + return { key, deleted: true } + } + + throw createError({ statusCode: 405, message: 'Method not allowed' }) +}) diff --git a/src/devtools-runtime/server/api/cache/clear.post.ts b/src/devtools-runtime/server/api/cache/clear.post.ts new file mode 100644 index 000000000..cc4503f06 --- /dev/null +++ b/src/devtools-runtime/server/api/cache/clear.post.ts @@ -0,0 +1,14 @@ +import { eventHandler, readBody, createError } from 'h3' +import { useStorage } from 'nitropack/runtime' +import { handleCors } from '../../utils/cors' + +export default eventHandler(async (event) => { + if (handleCors(event)) return + const body = await readBody(event) + if (body?.confirm !== 'CLEAR_ALL') { + throw createError({ statusCode: 400, message: 'Confirmation required: send { confirm: "CLEAR_ALL" }' }) + } + const cache = useStorage('cache') + await cache.clear() + return { cleared: true } +}) diff --git a/src/devtools-runtime/server/api/cache/index.get.ts b/src/devtools-runtime/server/api/cache/index.get.ts new file mode 100644 index 000000000..faeb5425b --- /dev/null +++ b/src/devtools-runtime/server/api/cache/index.get.ts @@ -0,0 +1,18 @@ +import { eventHandler, getQuery } from 'h3' +import { useStorage } from 'nitropack/runtime' +import { handleCors } from '../../utils/cors' + +export default eventHandler(async (event) => { + if (handleCors(event)) return + const { prefix = '' } = getQuery(event) + const cache = useStorage('cache') + const keys = await cache.getKeys(prefix as string) + return Promise.all(keys.map(async (key) => { + try { + const value = await cache.getItem(key) + return { key, value } + } catch { + return { key, value: null } + } + })) +}) diff --git a/src/devtools-runtime/server/api/kv/[key].ts b/src/devtools-runtime/server/api/kv/[key].ts new file mode 100644 index 000000000..a1e0fcf26 --- /dev/null +++ b/src/devtools-runtime/server/api/kv/[key].ts @@ -0,0 +1,30 @@ +import { eventHandler, getRouterParam, readBody, createError } from 'h3' +import { kv } from 'hub:kv' +import { handleCors } from '../../utils/cors' + +export default eventHandler(async (event) => { + if (handleCors(event)) return + const method = event.method + const key = decodeURIComponent(getRouterParam(event, 'key') || '') + if (!key) throw createError({ statusCode: 400, message: 'Key is required' }) + + if (method === 'GET') { + const value = await kv.get(key) + if (value === null) throw createError({ statusCode: 404, message: 'Key not found' }) + return { key, value } + } + + if (method === 'PUT') { + const body = await readBody(event) + const { value, ttl } = body + await kv.set(key, value, ttl ? { ttl } : undefined) + return { key, value } + } + + if (method === 'DELETE') { + await kv.del(key) + return { key, deleted: true } + } + + throw createError({ statusCode: 405, message: 'Method not allowed' }) +}) diff --git a/src/devtools-runtime/server/api/kv/clear.post.ts b/src/devtools-runtime/server/api/kv/clear.post.ts new file mode 100644 index 000000000..c4cb1be5a --- /dev/null +++ b/src/devtools-runtime/server/api/kv/clear.post.ts @@ -0,0 +1,13 @@ +import { eventHandler, readBody, createError } from 'h3' +import { kv } from 'hub:kv' +import { handleCors } from '../../utils/cors' + +export default eventHandler(async (event) => { + if (handleCors(event)) return + const body = await readBody(event) + if (body?.confirm !== 'CLEAR_ALL') { + throw createError({ statusCode: 400, message: 'Confirmation required: send { confirm: "CLEAR_ALL" }' }) + } + await kv.clear() + return { cleared: true } +}) diff --git a/src/devtools-runtime/server/api/kv/index.get.ts b/src/devtools-runtime/server/api/kv/index.get.ts new file mode 100644 index 000000000..9fc28be9d --- /dev/null +++ b/src/devtools-runtime/server/api/kv/index.get.ts @@ -0,0 +1,10 @@ +import { eventHandler, getQuery } from 'h3' +import { kv } from 'hub:kv' +import { handleCors } from '../../utils/cors' + +export default eventHandler(async (event) => { + if (handleCors(event)) return + const { prefix = '' } = getQuery(event) + const keys = await kv.keys(prefix as string) + return Promise.all(keys.map(async key => ({ key, value: await kv.get(key) }))) +}) diff --git a/src/devtools-runtime/server/utils/cors.ts b/src/devtools-runtime/server/utils/cors.ts new file mode 100644 index 000000000..dd6a1fdac --- /dev/null +++ b/src/devtools-runtime/server/utils/cors.ts @@ -0,0 +1,38 @@ +import { getHeader, sendNoContent, setHeader } from 'h3' +import type { H3Event } from 'h3' + +function isAllowedOrigin(origin: string) { + if (origin === 'https://hub.nuxt.com') return true + if (/^https:\/\/[a-z0-9-]+\.hub\.nuxt\.com$/i.test(origin)) return true + if (/^https?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?$/i.test(origin)) return true + return false +} + +/** + * Applies CORS headers for hosted-UI -> localhost calls. + * Returns true if request was handled as a preflight (OPTIONS). + */ +export function handleCors(event: H3Event) { + const origin = getHeader(event, 'origin') + if (origin && isAllowedOrigin(origin)) { + setHeader(event, 'access-control-allow-origin', origin) + setHeader(event, 'vary', 'origin') + } + + // Private Network Access preflights (Chrome). + if (getHeader(event, 'access-control-request-private-network') === 'true') { + setHeader(event, 'access-control-allow-private-network', 'true') + } + + if (event.method === 'OPTIONS') { + const requestedMethod = getHeader(event, 'access-control-request-method') + const requestedHeaders = getHeader(event, 'access-control-request-headers') + setHeader(event, 'access-control-allow-methods', requestedMethod || 'GET,POST,PUT,DELETE,HEAD,OPTIONS') + setHeader(event, 'access-control-allow-headers', requestedHeaders || 'content-type,x-ttl,x-nuxthub-file-content-type') + setHeader(event, 'access-control-max-age', '600') + sendNoContent(event) + return true + } + + return false +} diff --git a/src/devtools.ts b/src/devtools.ts index 323ba02ae..2a920060e 100644 --- a/src/devtools.ts +++ b/src/devtools.ts @@ -5,6 +5,7 @@ import { logger } from '@nuxt/kit' import type { Nuxt } from 'nuxt/schema' import type { HubConfig, ResolvedDatabaseConfig } from '@nuxthub/core' import { getPort } from 'get-port-please' +import { setupDevTools, addDevToolsStorageTabs } from './devtools/setup' let isReady = false let promise: Promise | null = null @@ -12,6 +13,8 @@ let port = 4983 const log = logger.withTag('nuxt:hub') +export { setupDevTools, addDevToolsStorageTabs } + async function launchDrizzleStudio(nuxt: Nuxt, hub: HubConfig) { const dbConfig = hub.db as ResolvedDatabaseConfig if (!dbConfig || typeof dbConfig === 'boolean' || typeof dbConfig === 'string') { @@ -79,16 +82,15 @@ async function launchDrizzleStudio(nuxt: Nuxt, hub: HubConfig) { export function addDevToolsCustomTabs(nuxt: Nuxt, hub: HubConfig) { nuxt.hook('devtools:customTabs', (tabs) => { - if (nuxt.options.nitro.experimental?.openAPI)({ - category: 'server', - name: 'hub-open-api', - title: 'OpenAPI', - icon: 'i-lucide-file-text', - view: { - type: 'iframe', - src: `/_scalar` - } - }) + if (nuxt.options.nitro.experimental?.openAPI) { + tabs.push({ + category: 'server', + name: 'hub-open-api', + title: 'OpenAPI', + icon: 'i-lucide-file-text', + view: { type: 'iframe', src: `/_scalar` } + }) + } if (hub.db) tabs.push({ category: 'server', diff --git a/src/devtools/origin.ts b/src/devtools/origin.ts new file mode 100644 index 000000000..032d88803 --- /dev/null +++ b/src/devtools/origin.ts @@ -0,0 +1,97 @@ +export type ResolveDevtoolsAppOriginOptions = { + /** + * Full origin override (e.g. "http://localhost:3010"). + * When provided, it wins over all other detection. + */ + appOrigin?: string + https?: boolean + /** + * Full dev server URL from Nuxt (e.g. "http://0.0.0.0:3010/"). + * Prefer this when available as it reflects the actual listening port. + */ + devServerUrl?: string + devServerPort?: number | string + argv?: string[] + env?: Record + defaultPort?: number +} + +function parsePort(input: unknown): number | undefined { + const n = typeof input === 'string' + ? Number.parseInt(input, 10) + : typeof input === 'number' + ? input + : Number.NaN + if (!Number.isFinite(n)) return + if (n <= 0 || n >= 65536) return + return Math.trunc(n) +} + +function portFromArgv(argv: string[]): number | undefined { + for (let i = 0; i < argv.length; i++) { + const arg = argv[i] + if (arg === '--port' || arg === '-p') { + const port = parsePort(argv[i + 1]) + if (port) return port + continue + } + if (arg.startsWith('--port=')) { + const port = parsePort(arg.slice('--port='.length)) + if (port) return port + continue + } + } +} + +function normalizeOrigin(value: string): string | undefined { + const trimmed = value.trim() + if (!trimmed) return + try { + return new URL(trimmed).origin + } catch { + if (/^https?:\/\//.test(trimmed)) + return trimmed + } +} + +function portFromDevServerUrl(value: string): number | undefined { + try { + const u = new URL(value) + // Nuxt should always provide an explicit port for dev server URLs, but keep a safe fallback. + const raw = u.port || (u.protocol === 'https:' ? '443' : '80') + return parsePort(raw) + } catch { + return + } +} + +export function resolveDevtoolsAppOrigin(opts: ResolveDevtoolsAppOriginOptions = {}) { + const normalizedOverride = opts.appOrigin ? normalizeOrigin(opts.appOrigin) : undefined + if (normalizedOverride) return normalizedOverride + + const protocol = opts.https ? 'https' : 'http' + const defaultPort = opts.defaultPort ?? 3000 + + const devServerUrlPort = typeof opts.devServerUrl === 'string' ? portFromDevServerUrl(opts.devServerUrl) : undefined + const nuxtPort = parsePort(opts.devServerPort) + const argvPort = opts.argv ? portFromArgv(opts.argv) : undefined + const envPort = opts.env + ? ( + parsePort(opts.env.PORT) + || parsePort(opts.env.NUXT_PORT) + || parsePort(opts.env.NITRO_PORT) + || parsePort(opts.env.npm_config_port) + ) + : undefined + + // If Nuxt provides a dev server URL, trust its port (it reflects the actual listening port, + // even when Nuxt auto-selects a non-default port because 3000 is busy). + if (devServerUrlPort) { + return `${protocol}://localhost:${devServerUrlPort}` + } + + // If Nuxt port is the default (3000), but the CLI explicitly sets a port, prefer CLI. + const port = (nuxtPort && nuxtPort !== defaultPort) ? nuxtPort : (argvPort || nuxtPort || envPort || defaultPort) + + return `${protocol}://localhost:${port}` +} diff --git a/src/devtools/setup.ts b/src/devtools/setup.ts new file mode 100644 index 000000000..161fbb533 --- /dev/null +++ b/src/devtools/setup.ts @@ -0,0 +1,112 @@ +import { addServerHandler, createResolver } from '@nuxt/kit' +import type { Nuxt } from '@nuxt/schema' +import type { HubConfig } from '@nuxthub/core' +import { joinURL, withQuery } from 'ufo' +import { resolveDevtoolsAppOrigin } from './origin' + +function resolveDevtoolsConfig(hub: HubConfig) { + const cfg = hub.devtools + const user = typeof cfg === 'object' ? cfg : {} + return { + remoteBaseUrl: user.remoteBaseUrl || 'https://hub.nuxt.com', + remotePathPrefix: user.remotePathPrefix || '/devtools', + appOrigin: user.appOrigin as string | undefined + } +} + +function getLocalOrigin(nuxt: Nuxt, hub: HubConfig) { + const { appOrigin } = resolveDevtoolsConfig(hub) + return resolveDevtoolsAppOrigin({ + appOrigin, + https: Boolean(nuxt.options.devServer.https), + devServerUrl: nuxt.options.devServer.url, + devServerPort: nuxt.options.devServer.port, + argv: process.argv, + env: process.env, + defaultPort: 3000 + }) +} + +export async function setupDevTools(nuxt: Nuxt, hub: HubConfig) { + if (!nuxt.options.dev) return + if (nuxt.options.test) return + if (hub.devtools === false) return + if (!hub.kv && !hub.blob && !hub.cache) return + + const { resolve } = createResolver(import.meta.url) + + // Register server handlers + if (hub.kv) { + addServerHandler({ route: '/_hub/devtools/kv', method: 'get', handler: resolve('../devtools-runtime/server/api/kv/index.get') }) + addServerHandler({ route: '/_hub/devtools/kv/clear', method: 'post', handler: resolve('../devtools-runtime/server/api/kv/clear.post') }) + addServerHandler({ route: '/_hub/devtools/kv/:key', handler: resolve('../devtools-runtime/server/api/kv/[key]') }) + + // Hosted DevTools UI compatibility (hub.nuxt.com) + addServerHandler({ route: '/api/_hub/kv', method: 'get', handler: resolve('../devtools-runtime/server/api/_hub/kv/[...key]') }) + addServerHandler({ route: '/api/_hub/kv/**:key', handler: resolve('../devtools-runtime/server/api/_hub/kv/[...key]') }) + } + if (hub.blob) { + addServerHandler({ route: '/_hub/devtools/blob', method: 'get', handler: resolve('../devtools-runtime/server/api/blob/index.get') }) + addServerHandler({ route: '/_hub/devtools/blob/**:pathname', handler: resolve('../devtools-runtime/server/api/blob/[...pathname]') }) + + // Hosted DevTools UI compatibility (hub.nuxt.com) + addServerHandler({ route: '/api/_hub/blob', method: 'get', handler: resolve('../devtools-runtime/server/api/_hub/blob/index.get') }) + addServerHandler({ route: '/api/_hub/blob', method: 'post', handler: resolve('../devtools-runtime/server/api/_hub/blob/index.post') }) + addServerHandler({ route: '/api/_hub/blob/head/**:pathname', method: 'get', handler: resolve('../devtools-runtime/server/api/_hub/blob/head.get') }) + addServerHandler({ route: '/api/_hub/blob/**:pathname', handler: resolve('../devtools-runtime/server/api/_hub/blob/[...pathname]') }) + } + if (hub.cache) { + addServerHandler({ route: '/_hub/devtools/cache', method: 'get', handler: resolve('../devtools-runtime/server/api/cache/index.get') }) + addServerHandler({ route: '/_hub/devtools/cache/clear', method: 'post', handler: resolve('../devtools-runtime/server/api/cache/clear.post') }) + addServerHandler({ route: '/_hub/devtools/cache/:key', handler: resolve('../devtools-runtime/server/api/cache/[key]') }) + + // Hosted DevTools UI compatibility (hub.nuxt.com) + addServerHandler({ route: '/api/_hub/cache', method: 'get', handler: resolve('../devtools-runtime/server/api/_hub/cache/index.get') }) + addServerHandler({ route: '/api/_hub/cache/batch-delete', method: 'post', handler: resolve('../devtools-runtime/server/api/_hub/cache/batch-delete.post') }) + addServerHandler({ route: '/api/_hub/cache/clear', method: 'delete', handler: resolve('../devtools-runtime/server/api/_hub/cache/clear.delete') }) + addServerHandler({ route: '/api/_hub/cache/clear/**:base', method: 'delete', handler: resolve('../devtools-runtime/server/api/_hub/cache/clear.delete') }) + addServerHandler({ route: '/api/_hub/cache/**:key', handler: resolve('../devtools-runtime/server/api/_hub/cache/[...key]') }) + } +} + +export function addDevToolsStorageTabs(nuxt: Nuxt, hub: HubConfig) { + if (!nuxt.options.dev) return + if (nuxt.options.test) return + if (hub.devtools === false) return + if (!hub.kv && !hub.blob && !hub.cache) return + + nuxt.hook('devtools:customTabs', (tabs) => { + const { remoteBaseUrl, remotePathPrefix } = resolveDevtoolsConfig(hub) + const localOrigin = getLocalOrigin(nuxt, hub) + const remote = (store: string) => withQuery(joinURL(remoteBaseUrl, remotePathPrefix, store), { url: localOrigin }) + + if (hub.kv) { + tabs.push({ + category: 'server', + name: 'hub-kv', + title: 'KV', + icon: 'i-lucide-key-round', + // Avoid caching stale `?url=...` across restarts. + view: { type: 'iframe', src: remote('kv'), persistent: false } + }) + } + if (hub.blob) { + tabs.push({ + category: 'server', + name: 'hub-blob', + title: 'Blob', + icon: 'i-lucide-hard-drive', + view: { type: 'iframe', src: remote('blob'), persistent: false } + }) + } + if (hub.cache) { + tabs.push({ + category: 'server', + name: 'hub-cache', + title: 'Cache', + icon: 'i-lucide-zap', + view: { type: 'iframe', src: remote('cache'), persistent: false } + }) + } + }) +} diff --git a/src/kv/setup.ts b/src/kv/setup.ts index 6e253fbea..55b14869a 100644 --- a/src/kv/setup.ts +++ b/src/kv/setup.ts @@ -1,5 +1,8 @@ -import { mkdir, writeFile, copyFile } from 'node:fs/promises' -import { join } from 'pathe' +import { mkdir, writeFile, copyFile, symlink } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { join, dirname } from 'pathe' +import { sep } from 'node:path' +import { fileURLToPath } from 'node:url' import { defu } from 'defu' import { addTypeTemplate, addServerImports } from '@nuxt/kit' import { resolve, logWhenReady, addWranglerBinding } from '../utils' @@ -144,6 +147,56 @@ export const kv = createStorage({ JSON.stringify(packageJson, null, 2) ) + const getPackageDirFromResolved = (resolved: string, pkgName: string) => { + const marker = `${sep}node_modules${sep}${pkgName}${sep}` + const idx = resolved.lastIndexOf(marker) + if (idx === -1) return + return resolved.slice(0, idx + marker.length - 1) + } + + // pnpm uses isolated dependency graphs. The generated physical package lives in the project + // root and won't see @nuxthub/core's dependencies unless we link them explicitly. + // Best-effort: do not fail user dev servers if symlinks aren't supported. + try { + const selfPath = fileURLToPath(import.meta.url) + const selfMarker = `${sep}node_modules${sep}@nuxthub${sep}core${sep}` + const selfIdx = selfPath.lastIndexOf(selfMarker) + const pnpmDepsDir = selfIdx === -1 ? undefined : `${selfPath.slice(0, selfIdx)}${sep}node_modules` + + const resolveFromEsm = (specifier: string) => { + const r = (import.meta as any).resolve?.(specifier) + if (!r) return + try { + return fileURLToPath(r) + } catch { + try { + return fileURLToPath(new URL(r)) + } catch { + return + } + } + } + + const req = createRequire(import.meta.url) + const unstorageDir = pnpmDepsDir + ? join(pnpmDepsDir, 'unstorage') + : ( + (() => { + const unstorageResolved = resolveFromEsm('unstorage') || req.resolve('unstorage') + return getPackageDirFromResolved(unstorageResolved, 'unstorage') || dirname(unstorageResolved) + })() + ) + + const physicalKvNodeModules = join(physicalKvDir, 'node_modules') + await mkdir(physicalKvNodeModules, { recursive: true }) + await symlink(unstorageDir, join(physicalKvNodeModules, 'unstorage'), 'dir').catch((e: any) => { + if (e?.code !== 'EEXIST') throw e + }) + } catch (e) { + // debug-only: this is a compatibility helper + logWhenReady(nuxt, `Failed to symlink unstorage for @nuxthub/kv: ${String((e as any)?.message || e)}`, 'debug') + } + // Create hub:kv alias to @nuxthub/kv for backwards compatibility nuxt.options.alias!['hub:kv'] = '@nuxthub/kv' diff --git a/src/module.ts b/src/module.ts index 0569a168d..fed0d709b 100644 --- a/src/module.ts +++ b/src/module.ts @@ -10,7 +10,7 @@ import { setupDatabase } from './db/setup' import { setupKV } from './kv/setup' import { setupBlob } from './blob/setup' import type { ModuleOptions, HubConfig, ResolvedHubConfig } from '@nuxthub/core' -import { addDevToolsCustomTabs } from './devtools' +import { addDevToolsCustomTabs, setupDevTools, addDevToolsStorageTabs } from './devtools' import { setupCloudflare } from './hosting/cloudflare' import type { NuxtModule } from '@nuxt/schema' @@ -44,7 +44,9 @@ export default defineNuxtModule({ blob: false, cache: false, db: false, - kv: false + kv: false, + // Nuxt DevTools integration (KV/Blob/Cache tabs) + devtools: {} }) as HubConfig // resolve the hub directory hub.dir = await resolveFs(nuxt.options.rootDir, hub.dir) @@ -79,8 +81,10 @@ export default defineNuxtModule({ } // Add custom tabs to Nuxt DevTools - if (nuxt.options.dev) { + if (nuxt.options.dev && hub.devtools !== false) { addDevToolsCustomTabs(nuxt, hub) + await setupDevTools(nuxt, hub) + addDevToolsStorageTabs(nuxt, hub) } // Enable Async Local Storage diff --git a/src/types/config.ts b/src/types/config.ts index c0edaa201..990a64105 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -8,6 +8,12 @@ export interface HubConfig { blob: boolean | BlobConfig cache: boolean | CacheConfig db: false | 'postgresql' | 'sqlite' | 'mysql' | DatabaseConfig + /** + * Nuxt DevTools integration for storage management tabs. + * + * Set `false` to disable KV/Blob/Cache tabs. + */ + devtools?: false | DevToolsConfig kv: boolean | KVConfig dir: string hosting: string @@ -58,6 +64,13 @@ export interface ModuleOptions { * @see https://hub.nuxt.com/docs/kv */ kv?: boolean | KVConfig + /** + * Nuxt DevTools integration for storage management tabs. + * + * By default, the UI is loaded from `https://hub.nuxt.com` and embedded as an iframe. + * Set to `false` to disable. + */ + devtools?: false | DevToolsConfig /** * The directory used for storage (database, kv, etc.) during local development. * @default '.data' @@ -70,6 +83,27 @@ export interface ModuleOptions { hosting?: string } +export type DevToolsConfig = { + /** + * Base URL for the hosted DevTools UI. + * + * @default 'https://hub.nuxt.com' + */ + remoteBaseUrl?: string + /** + * Path prefix for the hosted DevTools UI. + * + * @default '/devtools' + */ + remotePathPrefix?: string + /** + * Full local app origin override used to build the `?url=` query passed to the hosted UI. + * + * @example 'http://localhost:3010' + */ + appOrigin?: string +} + // Blob driver configurations - extend from driver option types export type FSBlobConfig = { driver: 'fs' } & FSDriverOptions export type S3BlobConfig = { driver: 's3' } & S3DriverOptions diff --git a/test/devtools-origin.test.ts b/test/devtools-origin.test.ts new file mode 100644 index 000000000..04f65a073 --- /dev/null +++ b/test/devtools-origin.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest' +import { resolveDevtoolsAppOrigin } from '../src/devtools/origin' + +describe('resolveDevtoolsAppOrigin', () => { + it('prefers devServer.url port (actual listening port)', () => { + const origin = resolveDevtoolsAppOrigin({ + https: false, + devServerUrl: 'http://0.0.0.0:3010/', + devServerPort: 3000, + argv: ['node', 'nuxt', 'dev'], + env: { PORT: '3000' } + }) + expect(origin).toBe('http://localhost:3010') + }) + + it('uses argv --port when devServer.port is missing', () => { + const origin = resolveDevtoolsAppOrigin({ + https: false, + devServerPort: undefined, + argv: ['node', 'nuxt', 'dev', '--port', '3010'] + }) + expect(origin).toBe('http://localhost:3010') + }) + + it('supports argv --port=4020', () => { + const origin = resolveDevtoolsAppOrigin({ + https: false, + argv: ['node', 'nuxt', 'dev', '--port=4020'] + }) + expect(origin).toBe('http://localhost:4020') + }) + + it('supports argv -p 1234', () => { + const origin = resolveDevtoolsAppOrigin({ + https: false, + argv: ['node', 'nuxt', 'dev', '-p', '1234'] + }) + expect(origin).toBe('http://localhost:1234') + }) + + it('prefers devServer.port when it is non-default', () => { + const origin = resolveDevtoolsAppOrigin({ + https: false, + devServerPort: 5555, + argv: ['node', 'nuxt', 'dev', '--port', '3010'] + }) + expect(origin).toBe('http://localhost:5555') + }) + + it('prefers argv over devServer.port when devServer.port is default (3000)', () => { + const origin = resolveDevtoolsAppOrigin({ + https: false, + devServerPort: 3000, + argv: ['node', 'nuxt', 'dev', '--port', '3010'] + }) + expect(origin).toBe('http://localhost:3010') + }) + + it('prefers appOrigin override over everything', () => { + const origin = resolveDevtoolsAppOrigin({ + appOrigin: 'http://localhost:7777', + https: false, + devServerPort: 5555, + argv: ['node', 'nuxt', 'dev', '--port', '3010'], + env: { PORT: '9999' } + }) + expect(origin).toBe('http://localhost:7777') + }) + + it('defaults to port 3000', () => { + const origin = resolveDevtoolsAppOrigin({ https: false, argv: [] }) + expect(origin).toBe('http://localhost:3000') + }) + + it('uses https when enabled', () => { + const origin = resolveDevtoolsAppOrigin({ + https: true, + devServerUrl: 'https://127.0.0.1:4443/', + argv: ['node', 'nuxt', 'dev', '--port', '3010'] + }) + expect(origin).toBe('https://localhost:4443') + }) +})