diff --git a/.gitignore b/.gitignore index 5fe05337..d3e5ae35 100644 --- a/.gitignore +++ b/.gitignore @@ -60,4 +60,7 @@ test/fixtures/basic/.data test/fixtures/kv/.data test/fixtures/blob/.data test/fixtures/openapi/.data -test/fixtures/cache/.data \ No newline at end of file +test/fixtures/cache/.data + +# Local PR notes +pr-body.md diff --git a/build.config.ts b/build.config.ts index e7aa418a..89f64e7e 100644 --- a/build.config.ts +++ b/build.config.ts @@ -45,6 +45,12 @@ export default defineBuildConfig({ outDir: 'dist/blob/types', builder: 'mkdist' }, + // Image + { + input: 'src/image/runtime/', + outDir: 'dist/image/runtime', + builder: 'mkdist' + }, // Cache { input: 'src/cache/runtime/', diff --git a/docs/content/docs/2.features/0.blob.md b/docs/content/docs/2.features/0.blob.md index f7d59c9b..3e5af729 100644 --- a/docs/content/docs/2.features/0.blob.md +++ b/docs/content/docs/2.features/0.blob.md @@ -758,6 +758,70 @@ Returns nothing. Throws an error if `file` doesn't meet the requirements. +## Image Optimization + +NuxtHub automatically integrates with [`@nuxt/image`](https://image.nuxt.com) when `hub.blob.image.path` is set. This allows you to use `` and `` components with automatic image optimization based on your hosting provider. + +### Setup + +1. Install `@nuxt/image` v2+: + +:pm-install{name="@nuxt/image"} + +::note +`@nuxt/image` version 2 or higher is required for NuxtHub integration. +:: + +2. Add the module to your `nuxt.config.ts`: + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + modules: ['@nuxthub/core', '@nuxt/image'], + hub: { + blob: { + image: { path: '/images' } + } + } +}) +``` + +The `path` option defines the URL prefix where your blob images are served (e.g., `/images`). This should match the route you create in step 3. Images in blob storage can be stored at any path - the `path` option only controls the URL routing, not the storage structure. + +3. Create a route to serve blob images: + +```ts [server/routes/images/[...pathname].get.ts] +import { blob } from 'hub:blob' + +export default defineEventHandler(async (event) => { + const pathname = getRouterParam(event, 'pathname') + return blob.serve(event, pathname!) +}) +``` + +4. Use `` in your components: + +```vue [pages/index.vue] + +``` + +### Provider Configuration + +NuxtHub registers a `nuxthub` provider for `@nuxt/image` that routes to built-in providers based on your blob driver: + +- **Cloudflare R2** uses [Cloudflare Image Resizing](https://image.nuxt.com/providers/cloudflare) +- **Vercel Blob** uses [Vercel Image Optimization](https://image.nuxt.com/providers/vercel) +- **Filesystem / S3** pass-through (no optimization) + +::note +To opt out or use a different provider, set `image.provider` explicitly in `nuxt.config.ts`. +:: + +### Development Mode + +During development, images are served directly from blob storage without any transformation, regardless of the configured driver. This ensures fast local development without requiring external services. + ## Vue Composables ::note diff --git a/src/blob/setup.ts b/src/blob/setup.ts index c9c8ea8c..b959bbb3 100644 --- a/src/blob/setup.ts +++ b/src/blob/setup.ts @@ -5,6 +5,7 @@ import { addTypeTemplate, addServerImports, addImportsDir, logger, addTemplate } import type { Nuxt } from '@nuxt/schema' import type { HubConfig, ResolvedBlobConfig } from '@nuxthub/core' import { resolve, logWhenReady } from '../utils' +import { setupImage } from '../image/setup' const log = logger.withTag('nuxt:hub') @@ -43,6 +44,9 @@ export function resolveBlobConfig(hub: HubConfig, deps: Record): if (!deps['@vercel/blob']) { log.error('Please run `npx nypm i @vercel/blob` to use Vercel Blob') } + if (!process.env.BLOB_READ_WRITE_TOKEN) { + log.warn('Set BLOB_READ_WRITE_TOKEN env var for Vercel Blob storage') + } return defu(hub.blob, { driver: 'vercel-blob', access: 'public' @@ -64,7 +68,7 @@ export function resolveBlobConfig(hub: HubConfig, deps: Record): }) as ResolvedBlobConfig } -export function setupBlob(nuxt: Nuxt, hub: HubConfig, deps: Record) { +export async function setupBlob(nuxt: Nuxt, hub: HubConfig, deps: Record) { hub.blob = resolveBlobConfig(hub, deps) if (!hub.blob) return @@ -107,5 +111,8 @@ export const blob = createBlobStorage(createDriver(${JSON.stringify(driverOption logWhenReady(nuxt, 'Files stored in Vercel Blob are public. Manually configure a different storage driver if storing sensitive files.', 'warn') } + // Setup @nuxt/image provider + await setupImage(nuxt, hub, deps) + logWhenReady(nuxt, `\`hub:blob\` using \`${blobConfig.driver}\` driver`) } diff --git a/src/image/runtime/provider.ts b/src/image/runtime/provider.ts new file mode 100644 index 00000000..155e2213 --- /dev/null +++ b/src/image/runtime/provider.ts @@ -0,0 +1,50 @@ +import { joinURL } from 'ufo' +import { defineProvider } from '@nuxt/image/runtime' +import cloudflareProvider from '@nuxt/image/runtime/providers/cloudflare' +import vercelProvider from '@nuxt/image/runtime/providers/vercel' + +const cfProvider = cloudflareProvider() +const vercelProviderInstance = vercelProvider() + +export function getImage( + src: string, + { + modifiers = {}, + baseURL, + driver, + path + }: { + modifiers?: Record + baseURL?: string + driver?: string + path?: string + } = {}, + ctx?: any +) { + const hasProtocol = /^[a-z][a-z0-9+.-]*:/.test(src) + const isAbsolute = hasProtocol || src.startsWith('/') + const passThroughBase = path || baseURL || '/' + const resolvedSrc = isAbsolute ? src : joinURL(passThroughBase, src) + + // Dev: pass-through (no optimization available locally) + if (import.meta.dev) { + return { url: resolvedSrc } + } + + const safeCtx = ctx || { options: { screens: {}, domains: [] } } + + // Cloudflare R2 -> CF Image Resizing + if (driver === 'cloudflare-r2') { + return cfProvider.getImage(resolvedSrc, { modifiers, baseURL }, safeCtx) + } + + // Vercel Blob -> Vercel Image Optimization + if (driver === 'vercel-blob') { + return vercelProviderInstance.getImage(resolvedSrc, { modifiers, baseURL }, safeCtx) + } + + // S3/FS or fallback: no optimization, pass-through + return { url: resolvedSrc } +} + +export default defineProvider({ getImage }) diff --git a/src/image/setup.ts b/src/image/setup.ts new file mode 100644 index 00000000..105ed2b7 --- /dev/null +++ b/src/image/setup.ts @@ -0,0 +1,60 @@ +import type { Nuxt } from '@nuxt/schema' +import type { HubConfig, ResolvedBlobConfig } from '@nuxthub/core' +import { resolvePath } from '@nuxt/kit' +import { defu } from 'defu' +import { resolve, logWhenReady } from '../utils' + +export async function setupImage(nuxt: Nuxt, hub: HubConfig, deps: Record) { + if (!hub.blob) return + + const blobConfig = hub.blob as ResolvedBlobConfig + const imagePath = blobConfig.image?.path + if (!imagePath) return + + const imageVersion = deps['@nuxt/image'] + if (!imageVersion) { + logWhenReady(nuxt, 'Install @nuxt/image v2+ to enable NuxtHub image optimization', 'warn') + return + } + + // Check if @nuxt/image v2+ by trying to resolve runtime path (v1 doesn't export it) + try { + await resolvePath('@nuxt/image/runtime') + } catch { + logWhenReady(nuxt, '@nuxt/image v2+ is required for NuxtHub integration. Please upgrade to @nuxt/image@^2.0.0', 'warn') + return + } + + const normalizedPath = (imagePath.startsWith('/') ? imagePath : `/${imagePath}`) + .replace(/\/+$/, '') || '/' + + // Register nuxthub provider for @nuxt/image + // @ts-expect-error image options from @nuxt/image + nuxt.options.image ||= {} + // @ts-expect-error image options from @nuxt/image + nuxt.options.image.providers ||= {} + + // Respect any existing user config while ensuring required defaults. + // @ts-expect-error image options from @nuxt/image + const existingProvider = nuxt.options.image.providers.nuxthub as any | undefined + const providerPath = resolve('image/runtime/provider') + + // If user configured a different provider under the "nuxthub" key, respect it. + const isCustomProvider = existingProvider?.provider && existingProvider.provider !== providerPath + if (!isCustomProvider) { + // @ts-expect-error image options from @nuxt/image + nuxt.options.image.providers.nuxthub = defu(existingProvider || {}, { + provider: providerPath, + options: { driver: blobConfig.driver, path: normalizedPath } + }) + } + + // Set as default provider if not already set + // @ts-expect-error image options from @nuxt/image + if (!nuxt.options.image.provider || nuxt.options.image.provider === 'auto') { + // @ts-expect-error image options from @nuxt/image + nuxt.options.image.provider = 'nuxthub' + } + + logWhenReady(nuxt, `\`@nuxt/image\` nuxthub provider registered (${blobConfig.driver})`) +} diff --git a/src/types/config.ts b/src/types/config.ts index 9de2de08..4e9438dd 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -70,11 +70,30 @@ export interface ModuleOptions { hosting?: string } +// Blob image config for @nuxt/image integration +export interface BlobImageConfig { + /** + * Path where blob images are served via your route handler. + * Enables automatic @nuxt/image configuration. + * @example '/images' + */ + path: string +} + +// Base blob config with shared options +interface BaseBlobConfig { + /** + * @nuxt/image integration settings. + * Set `image.path` to match your blob serve route. + */ + image?: BlobImageConfig +} + // Blob driver configurations - extend from driver option types -export type FSBlobConfig = { driver: 'fs' } & FSDriverOptions -export type S3BlobConfig = { driver: 's3' } & S3DriverOptions -export type VercelBlobConfig = { driver: 'vercel-blob' } & VercelDriverOptions -export type CloudflareR2BlobConfig = { driver: 'cloudflare-r2' } & CloudflareDriverOptions +export type FSBlobConfig = { driver: 'fs' } & FSDriverOptions & BaseBlobConfig +export type S3BlobConfig = { driver: 's3' } & S3DriverOptions & BaseBlobConfig +export type VercelBlobConfig = { driver: 'vercel-blob' } & VercelDriverOptions & BaseBlobConfig +export type CloudflareR2BlobConfig = { driver: 'cloudflare-r2' } & CloudflareDriverOptions & BaseBlobConfig export type BlobConfig = boolean | FSBlobConfig | S3BlobConfig | VercelBlobConfig | CloudflareR2BlobConfig export type ResolvedBlobConfig = FSBlobConfig | S3BlobConfig | VercelBlobConfig | CloudflareR2BlobConfig diff --git a/test/fixtures/image/nuxt.config.ts b/test/fixtures/image/nuxt.config.ts new file mode 100644 index 00000000..f0b1a886 --- /dev/null +++ b/test/fixtures/image/nuxt.config.ts @@ -0,0 +1,19 @@ +import { defineNuxtConfig } from 'nuxt/config' + +export default defineNuxtConfig({ + extends: [ + '../basic' + ], + modules: [ + '../../../src/module', + '@nuxt/image' + ], + hub: { + blob: { + driver: 'fs', + image: { + path: '/images' + } + } + } +}) diff --git a/test/fixtures/image/package.json b/test/fixtures/image/package.json new file mode 100644 index 00000000..6cbc0678 --- /dev/null +++ b/test/fixtures/image/package.json @@ -0,0 +1,9 @@ +{ + "private": true, + "name": "hub-image-test", + "type": "module", + "devDependencies": { + "@nuxt/image": "*" + } +} + diff --git a/test/fixtures/image/server/routes/images/_url.get.ts b/test/fixtures/image/server/routes/images/_url.get.ts new file mode 100644 index 00000000..f86cd6d6 --- /dev/null +++ b/test/fixtures/image/server/routes/images/_url.get.ts @@ -0,0 +1,15 @@ +import { defineEventHandler, getQuery } from 'h3' +import { useImage } from '#imports' + +export default defineEventHandler((event) => { + const query = getQuery(event) + const driver = typeof query.driver === 'string' ? query.driver : undefined + const width = typeof query.w === 'string' ? Number(query.w) : 300 + const quality = typeof query.q === 'string' ? Number(query.q) : 80 + const src = typeof query.src === 'string' ? query.src : '/images/photo.jpg' + + const img = useImage(event) + const url = img(src, { width, quality }, { provider: 'nuxthub', driver }) + + return { url } +}) diff --git a/test/image.e2e.test.ts b/test/image.e2e.test.ts new file mode 100644 index 00000000..9022c277 --- /dev/null +++ b/test/image.e2e.test.ts @@ -0,0 +1,37 @@ +import { fileURLToPath } from 'node:url' +import { describe, it, expect } from 'vitest' +import { setup, $fetch } from '@nuxt/test-utils' + +describe('Image provider e2e', async () => { + await setup({ + rootDir: fileURLToPath(new URL('./fixtures/image', import.meta.url)), + dev: false + }) + + it('generates Cloudflare Image Resizing URL for R2', async () => { + const { url } = await $fetch<{ url: string }>('/images/_url', { + query: { driver: 'cloudflare-r2', w: 300, q: 80 } + }) + expect(url).toContain('/cdn-cgi/image/') + expect(url).toContain('w=300') + expect(url).toContain('q=80') + expect(url).toContain('/images/photo.jpg') + }) + + it('generates Vercel Image Optimization URL for Vercel Blob', async () => { + const { url } = await $fetch<{ url: string }>('/images/_url', { + query: { driver: 'vercel-blob', w: 300, q: 80 } + }) + expect(url.startsWith('/_vercel/image?')).toBe(true) + expect(url).toContain('url=%2Fimages%2Fphoto.jpg') + expect(url).toMatch(/[?&]w=\d+/) + expect(url).toContain('q=80') + }) + + it('passes through URL for other drivers', async () => { + const { url } = await $fetch<{ url: string }>('/images/_url', { + query: { driver: 's3', w: 300, q: 80 } + }) + expect(url).toBe('/images/photo.jpg') + }) +}) diff --git a/test/image.integration.test.ts b/test/image.integration.test.ts new file mode 100644 index 00000000..d7cfdcc8 --- /dev/null +++ b/test/image.integration.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest' +import buildConfig from '../build.config' +import { setupImage } from '../src/image/setup' + +describe('Image integration', () => { + it('ships image runtime provider in build config', () => { + const configs = buildConfig as any[] + const entries = configs[0]?.entries as any[] + const hasImageRuntime = entries.some((entry) => { + return typeof entry === 'object' + && entry.input === 'src/image/runtime/' + && entry.outDir === 'dist/image/runtime' + }) + expect(hasImageRuntime).toBe(true) + }) + + it('registers nuxthub provider only when image.path is set', async () => { + const deps = { '@nuxt/image': '^2.0.0' } + + const nuxtNoPath: any = { options: { _prepare: false, dev: false } } + const hubNoPath: any = { blob: { driver: 'fs' } } + await setupImage(nuxtNoPath, hubNoPath, deps) + expect(nuxtNoPath.options.image).toBeUndefined() + + const nuxtWithPath: any = { options: { _prepare: false, dev: false } } + const hubWithPath: any = { blob: { driver: 'fs', image: { path: 'images/' } } } + await setupImage(nuxtWithPath, hubWithPath, deps) + + expect(nuxtWithPath.options.image.provider).toBe('nuxthub') + expect(nuxtWithPath.options.image.providers?.nuxthub).toBeDefined() + expect(nuxtWithPath.options.image.providers.nuxthub.provider).toContain('image/runtime/provider') + expect(nuxtWithPath.options.image.providers.nuxthub.options).toMatchObject({ + driver: 'fs', + path: '/images' + }) + }) + + it('skips setup when @nuxt/image is not installed', async () => { + const deps = {} // no @nuxt/image + const nuxt: any = { options: { _prepare: false, dev: false } } + const hub: any = { blob: { driver: 'fs', image: { path: '/images' } } } + await setupImage(nuxt, hub, deps) + expect(nuxt.options.image).toBeUndefined() + }) +})