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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,7 @@ test/fixtures/basic/.data
test/fixtures/kv/.data
test/fixtures/blob/.data
test/fixtures/openapi/.data
test/fixtures/cache/.data
test/fixtures/cache/.data

# Local PR notes
pr-body.md
6 changes: 6 additions & 0 deletions build.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/',
Expand Down
64 changes: 64 additions & 0 deletions docs/content/docs/2.features/0.blob.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<NuxtImg>` and `<NuxtPicture>` 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 `<NuxtImg>` in your components:

```vue [pages/index.vue]
<template>
<NuxtImg src="/images/avatars/john.jpg" width="300" quality="80" />
</template>
```

### 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
Expand Down
9 changes: 8 additions & 1 deletion src/blob/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down Expand Up @@ -43,6 +44,9 @@ export function resolveBlobConfig(hub: HubConfig, deps: Record<string, string>):
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'
Expand All @@ -64,7 +68,7 @@ export function resolveBlobConfig(hub: HubConfig, deps: Record<string, string>):
}) as ResolvedBlobConfig
}

export function setupBlob(nuxt: Nuxt, hub: HubConfig, deps: Record<string, string>) {
export async function setupBlob(nuxt: Nuxt, hub: HubConfig, deps: Record<string, string>) {
hub.blob = resolveBlobConfig(hub, deps)
if (!hub.blob) return

Expand Down Expand Up @@ -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`)
}
50 changes: 50 additions & 0 deletions src/image/runtime/provider.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>
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 })
60 changes: 60 additions & 0 deletions src/image/setup.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>) {
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})`)
}
27 changes: 23 additions & 4 deletions src/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions test/fixtures/image/nuxt.config.ts
Original file line number Diff line number Diff line change
@@ -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'
}
}
}
})
9 changes: 9 additions & 0 deletions test/fixtures/image/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"private": true,
"name": "hub-image-test",
"type": "module",
"devDependencies": {
"@nuxt/image": "*"
}
}

15 changes: 15 additions & 0 deletions test/fixtures/image/server/routes/images/_url.get.ts
Original file line number Diff line number Diff line change
@@ -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 }
})
37 changes: 37 additions & 0 deletions test/image.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
Loading
Loading