Skip to content

Commit 9b7406d

Browse files
committed
feat(image): integrate @nuxt/image with hub blob
1 parent 9aa0b03 commit 9b7406d

File tree

13 files changed

+340
-14
lines changed

13 files changed

+340
-14
lines changed

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,4 +60,7 @@ test/fixtures/basic/.data
6060
test/fixtures/kv/.data
6161
test/fixtures/blob/.data
6262
test/fixtures/openapi/.data
63-
test/fixtures/cache/.data
63+
test/fixtures/cache/.data
64+
65+
# Local PR notes
66+
pr-body.md

build.config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,12 @@ export default defineBuildConfig({
4545
outDir: 'dist/blob/types',
4646
builder: 'mkdist'
4747
},
48+
// Image
49+
{
50+
input: 'src/image/runtime/',
51+
outDir: 'dist/image/runtime',
52+
builder: 'mkdist'
53+
},
4854
// Cache
4955
{
5056
input: 'src/cache/runtime/',

docs/app/pages/templates.vue

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ interface Template {
77
repo: string
88
features: string[]
99
demoUrl: string
10-
workersPaid: boolean
1110
slug: string
1211
}
1312
@@ -81,13 +80,6 @@ import.meta.server && defineOgImageComponent('Docs')
8180
{{ template.description }}
8281
</p>
8382
<div class="flex items-center flex-wrap gap-1">
84-
<UBadge
85-
v-if="template.workersPaid"
86-
label="Workers Paid"
87-
variant="subtle"
88-
size="sm"
89-
class="rounded-full"
90-
/>
9183
<template v-for="feature of template.features" :key="feature.name">
9284
<NuxtLink v-if="feature.hasPage" :to="`/docs/features/${feature.name}`">
9385
<UBadge

docs/content/docs/2.features/0.blob.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -758,6 +758,70 @@ Returns nothing.
758758

759759
Throws an error if `file` doesn't meet the requirements.
760760

761+
## Image Optimization
762+
763+
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.
764+
765+
### Setup
766+
767+
1. Install `@nuxt/image` v2+:
768+
769+
:pm-install{name="@nuxt/image"}
770+
771+
::note
772+
`@nuxt/image` version 2 or higher is required for NuxtHub integration.
773+
::
774+
775+
2. Add the module to your `nuxt.config.ts`:
776+
777+
```ts [nuxt.config.ts]
778+
export default defineNuxtConfig({
779+
modules: ['@nuxthub/core', '@nuxt/image'],
780+
hub: {
781+
blob: {
782+
image: { path: '/images' }
783+
}
784+
}
785+
})
786+
```
787+
788+
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.
789+
790+
3. Create a route to serve blob images:
791+
792+
```ts [server/routes/images/[...pathname].get.ts]
793+
import { blob } from 'hub:blob'
794+
795+
export default defineEventHandler(async (event) => {
796+
const pathname = getRouterParam(event, 'pathname')
797+
return blob.serve(event, pathname!)
798+
})
799+
```
800+
801+
4. Use `<NuxtImg>` in your components:
802+
803+
```vue [pages/index.vue]
804+
<template>
805+
<NuxtImg src="/images/avatars/john.jpg" width="300" quality="80" />
806+
</template>
807+
```
808+
809+
### Provider Configuration
810+
811+
NuxtHub registers a `nuxthub` provider for `@nuxt/image` that routes to built-in providers based on your blob driver:
812+
813+
- **Cloudflare R2** uses [Cloudflare Image Resizing](https://image.nuxt.com/providers/cloudflare)
814+
- **Vercel Blob** uses [Vercel Image Optimization](https://image.nuxt.com/providers/vercel)
815+
- **Filesystem / S3** pass-through (no optimization)
816+
817+
::note
818+
To opt out or use a different provider, set `image.provider` explicitly in `nuxt.config.ts`.
819+
::
820+
821+
### Development Mode
822+
823+
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.
824+
761825
## Vue Composables
762826

763827
::note

src/blob/setup.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { addTypeTemplate, addServerImports, addImportsDir, logger, addTemplate }
55
import type { Nuxt } from '@nuxt/schema'
66
import type { HubConfig, ResolvedBlobConfig } from '@nuxthub/core'
77
import { resolve, logWhenReady } from '../utils'
8+
import { setupImage } from '../image/setup'
89

910
const log = logger.withTag('nuxt:hub')
1011

@@ -43,6 +44,9 @@ export function resolveBlobConfig(hub: HubConfig, deps: Record<string, string>):
4344
if (!deps['@vercel/blob']) {
4445
log.error('Please run `npx nypm i @vercel/blob` to use Vercel Blob')
4546
}
47+
if (!process.env.BLOB_READ_WRITE_TOKEN) {
48+
log.warn('Set BLOB_READ_WRITE_TOKEN env var for Vercel Blob storage')
49+
}
4650
return defu(hub.blob, {
4751
driver: 'vercel-blob',
4852
access: 'public'
@@ -64,7 +68,7 @@ export function resolveBlobConfig(hub: HubConfig, deps: Record<string, string>):
6468
}) as ResolvedBlobConfig
6569
}
6670

67-
export function setupBlob(nuxt: Nuxt, hub: HubConfig, deps: Record<string, string>) {
71+
export async function setupBlob(nuxt: Nuxt, hub: HubConfig, deps: Record<string, string>) {
6872
hub.blob = resolveBlobConfig(hub, deps)
6973
if (!hub.blob) return
7074

@@ -107,5 +111,8 @@ export const blob = createBlobStorage(createDriver(${JSON.stringify(driverOption
107111
logWhenReady(nuxt, 'Files stored in Vercel Blob are public. Manually configure a different storage driver if storing sensitive files.', 'warn')
108112
}
109113

114+
// Setup @nuxt/image provider
115+
await setupImage(nuxt, hub, deps)
116+
110117
logWhenReady(nuxt, `\`hub:blob\` using \`${blobConfig.driver}\` driver`)
111118
}

src/image/runtime/provider.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { joinURL } from 'ufo'
2+
import { defineProvider } from '@nuxt/image/runtime'
3+
import cloudflareProvider from '@nuxt/image/runtime/providers/cloudflare'
4+
import vercelProvider from '@nuxt/image/runtime/providers/vercel'
5+
6+
const cfProvider = cloudflareProvider()
7+
const vercelProviderInstance = vercelProvider()
8+
9+
export function getImage(
10+
src: string,
11+
{
12+
modifiers = {},
13+
baseURL,
14+
driver,
15+
path
16+
}: {
17+
modifiers?: Record<string, any>
18+
baseURL?: string
19+
driver?: string
20+
path?: string
21+
} = {},
22+
ctx?: any
23+
) {
24+
const hasProtocol = /^[a-z][a-z0-9+.-]*:/.test(src)
25+
const isAbsolute = hasProtocol || src.startsWith('/')
26+
const passThroughBase = path || baseURL || '/'
27+
const resolvedSrc = isAbsolute ? src : joinURL(passThroughBase, src)
28+
29+
// Dev: pass-through (no optimization available locally)
30+
if (import.meta.dev) {
31+
return { url: resolvedSrc }
32+
}
33+
34+
const safeCtx = ctx || { options: { screens: {}, domains: [] } }
35+
36+
// Cloudflare R2 -> CF Image Resizing
37+
if (driver === 'cloudflare-r2') {
38+
return cfProvider.getImage(resolvedSrc, { modifiers, baseURL }, safeCtx)
39+
}
40+
41+
// Vercel Blob -> Vercel Image Optimization
42+
if (driver === 'vercel-blob') {
43+
return vercelProviderInstance.getImage(resolvedSrc, { modifiers, baseURL }, safeCtx)
44+
}
45+
46+
// S3/FS or fallback: no optimization, pass-through
47+
return { url: resolvedSrc }
48+
}
49+
50+
export default defineProvider({ getImage })

src/image/setup.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import type { Nuxt } from '@nuxt/schema'
2+
import type { HubConfig, ResolvedBlobConfig } from '@nuxthub/core'
3+
import { resolvePath } from '@nuxt/kit'
4+
import { defu } from 'defu'
5+
import { resolve, logWhenReady } from '../utils'
6+
7+
export async function setupImage(nuxt: Nuxt, hub: HubConfig, deps: Record<string, string>) {
8+
if (!hub.blob) return
9+
10+
const blobConfig = hub.blob as ResolvedBlobConfig
11+
const imagePath = blobConfig.image?.path
12+
if (!imagePath) return
13+
14+
const imageVersion = deps['@nuxt/image']
15+
if (!imageVersion) {
16+
logWhenReady(nuxt, 'Install @nuxt/image v2+ to enable NuxtHub image optimization', 'warn')
17+
return
18+
}
19+
20+
// Check if @nuxt/image v2+ by trying to resolve runtime path (v1 doesn't export it)
21+
try {
22+
await resolvePath('@nuxt/image/runtime')
23+
} catch {
24+
logWhenReady(nuxt, '@nuxt/image v2+ is required for NuxtHub integration. Please upgrade to @nuxt/image@^2.0.0', 'warn')
25+
return
26+
}
27+
28+
const normalizedPath = (imagePath.startsWith('/') ? imagePath : `/${imagePath}`)
29+
.replace(/\/+$/, '') || '/'
30+
31+
// Register nuxthub provider for @nuxt/image
32+
// @ts-expect-error image options from @nuxt/image
33+
nuxt.options.image ||= {}
34+
// @ts-expect-error image options from @nuxt/image
35+
nuxt.options.image.providers ||= {}
36+
37+
// Respect any existing user config while ensuring required defaults.
38+
// @ts-expect-error image options from @nuxt/image
39+
const existingProvider = nuxt.options.image.providers.nuxthub as any | undefined
40+
const providerPath = resolve('image/runtime/provider')
41+
42+
// If user configured a different provider under the "nuxthub" key, respect it.
43+
const isCustomProvider = existingProvider?.provider && existingProvider.provider !== providerPath
44+
if (!isCustomProvider) {
45+
// @ts-expect-error image options from @nuxt/image
46+
nuxt.options.image.providers.nuxthub = defu(existingProvider || {}, {
47+
provider: providerPath,
48+
options: { driver: blobConfig.driver, path: normalizedPath }
49+
})
50+
}
51+
52+
// Set as default provider if not already set
53+
// @ts-expect-error image options from @nuxt/image
54+
if (!nuxt.options.image.provider || nuxt.options.image.provider === 'auto') {
55+
// @ts-expect-error image options from @nuxt/image
56+
nuxt.options.image.provider = 'nuxthub'
57+
}
58+
59+
logWhenReady(nuxt, `\`@nuxt/image\` nuxthub provider registered (${blobConfig.driver})`)
60+
}

src/types/config.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,11 +70,30 @@ export interface ModuleOptions {
7070
hosting?: string
7171
}
7272

73+
// Blob image config for @nuxt/image integration
74+
export interface BlobImageConfig {
75+
/**
76+
* Path where blob images are served via your route handler.
77+
* Enables automatic @nuxt/image configuration.
78+
* @example '/images'
79+
*/
80+
path: string
81+
}
82+
83+
// Base blob config with shared options
84+
interface BaseBlobConfig {
85+
/**
86+
* @nuxt/image integration settings.
87+
* Set `image.path` to match your blob serve route.
88+
*/
89+
image?: BlobImageConfig
90+
}
91+
7392
// Blob driver configurations - extend from driver option types
74-
export type FSBlobConfig = { driver: 'fs' } & FSDriverOptions
75-
export type S3BlobConfig = { driver: 's3' } & S3DriverOptions
76-
export type VercelBlobConfig = { driver: 'vercel-blob' } & VercelDriverOptions
77-
export type CloudflareR2BlobConfig = { driver: 'cloudflare-r2' } & CloudflareDriverOptions
93+
export type FSBlobConfig = { driver: 'fs' } & FSDriverOptions & BaseBlobConfig
94+
export type S3BlobConfig = { driver: 's3' } & S3DriverOptions & BaseBlobConfig
95+
export type VercelBlobConfig = { driver: 'vercel-blob' } & VercelDriverOptions & BaseBlobConfig
96+
export type CloudflareR2BlobConfig = { driver: 'cloudflare-r2' } & CloudflareDriverOptions & BaseBlobConfig
7897

7998
export type BlobConfig = boolean | FSBlobConfig | S3BlobConfig | VercelBlobConfig | CloudflareR2BlobConfig
8099
export type ResolvedBlobConfig = FSBlobConfig | S3BlobConfig | VercelBlobConfig | CloudflareR2BlobConfig

test/fixtures/image/nuxt.config.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { defineNuxtConfig } from 'nuxt/config'
2+
3+
export default defineNuxtConfig({
4+
extends: [
5+
'../basic'
6+
],
7+
modules: [
8+
'../../../src/module',
9+
'@nuxt/image'
10+
],
11+
hub: {
12+
blob: {
13+
driver: 'fs',
14+
image: {
15+
path: '/images'
16+
}
17+
}
18+
}
19+
})

test/fixtures/image/package.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"private": true,
3+
"name": "hub-image-test",
4+
"type": "module",
5+
"devDependencies": {
6+
"@nuxt/image": "*"
7+
}
8+
}
9+

0 commit comments

Comments
 (0)