Skip to content

feat(image): image transformation #305

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
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
88 changes: 88 additions & 0 deletions docs/content/1.docs/2.features/image.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
---
title: Image Transformation
navigation.title: Image
description: Add image transformation to your NuxtHub project.
---

## Getting Started

Enable the image transformation in your NuxtHub project by adding the `image` property to the `hub` object in your `nuxt.config.ts` file.


```ts [nuxt.config.ts]
export default defineNuxtConfig({
hub: {
image: {
trustedDomains: ['hub.nuxt.com'],
templates: {
small: { width: 128, height: 128, format: 'webp' },
medium: { width: 512, height: 512, format: 'webp' }
}
}
}
})
```

NuxtHub will add an specific route to your project to transform the image. The route is `/_hub/image/<template>/<source-image>`. For example, to transform an image with the `small` template, you can use the following URL:

```html
<img src="https://hub.nuxt.com/_hub/image/small/example-image.jpg" />
```




## Trusted Domains

By default the image transformation is not allowed for any remote images and you can only transform images that are uploaded via `hubBlob()`. If you want to allow remote images, you can add the trusted domains to the `trustedDomains` option.

```ts [nuxt.config.ts]
export default defineNuxtConfig({
hub: {
image: {
trustedDomains: ['hub.nuxt.com', 'example.com']
}
}
})
```

## Templates

In order to improve security and prevent service abuse, you need to define transformation templates for your images. You can define as many templates as you want. These templates will be used to transform the requested image.

```ts [nuxt.config.ts]
export default defineNuxtConfig({
hub: {
image: {
templates: {
small: { width: 128, height: 128, format: 'webp' },
medium: { width: 512, height: 512, format: 'webp' }
}
}
}
})
```

### Template Options

The template options can be defined for each template.

#### `width`

The width of the transformed image in pixels.

#### `height`

The height of the transformed image in pixels.

#### `format`

The format of the transformed image. Can be `png`, `jpeg` or `webp`.

#### `jpeg_quality`

The quality of the JPEG image. This option is only used when the `format` is `jpeg`.

#### `rotate`

The angle of rotation in degrees.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -38,6 +38,7 @@
"test:watch": "vitest watch"
},
"dependencies": {
"@cf-wasm/photon": "^0.1.24",
"@cloudflare/workers-types": "^4.20240925.0",
"@nuxt/devtools-kit": "^1.5.1",
"@nuxt/kit": "^3.13.2",
@@ -55,6 +56,7 @@
"ufo": "^1.5.4",
"uncrypto": "^0.1.3",
"unstorage": "^1.12.0",
"unwasm": "^0.3.9",
"zod": "^3.23.8"
},
"devDependencies": {
1 change: 1 addition & 0 deletions playground/app/layouts/default.vue
Original file line number Diff line number Diff line change
@@ -20,6 +20,7 @@ const links = [
{ label: 'AI', to: '/ai' },
{ label: 'Browser', to: '/browser' },
{ label: 'Blob', to: '/blob' },
{ label: 'Image', to: '/image' },
{ label: 'Database', to: '/database' },
{ label: 'KV', to: '/kv' }
]
79 changes: 79 additions & 0 deletions playground/app/pages/image.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<script setup lang="ts">
const source = ref<string>('https://hub.nuxt.com/images/landing/nuxthub-schema.png')
const options = reactive({
width: 600,
height: 400,
format: 'webp',
rotate: 0
})
const { data: blobData } = await useFetch('/api/blob', {
query: {
folded: false,
limit: 4
},
deep: true
})
const imageSrc = computed(() => {
return `/_hub/image/${Object.entries(options).map(([key, value]) => `${key}=${value}`).join(',')}/${source.value}`
})
const files = computed(() => blobData.value?.blobs || [])
</script>

<template>
<UCard>
<div class="flex gap-4 mb-4">
<UFormGroup label="Width x Height">
<div class="flex gap-2">
<USelect
v-model="options.width"
:options="[300, 600, 900, 1200]"
/>
x
<USelect
v-model="options.height"
:options="[200, 400, 600, 800]"
/>
</div>
</UFormGroup>
<UFormGroup label="Rotate">
<USelect
v-model="options.rotate"
:options="[0, 90, 180, 270]"
/>
</UFormGroup>
<UFormGroup label="Format">
<USelect
v-model="options.format"
:options="['webp', 'jpeg', 'png']"
/>
</UFormGroup>
</div>
<div class="flex-1 h-96 flex items-center justify-center relative">
<UProgress animation="carousel" class="w-32 absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2" />

<img :key="imageSrc" :src="imageSrc" class="z-10 w-full h-full object-contain">
</div>
<div class="flex-2">
<div v-if="files?.length" class="flex overflow-scroll gap-2 mt-4">
<UCard
v-for="file of files"
:key="file.pathname"
:ui="{
body: {
base: 'space-y-0',
padding: ''
}
}"
class="overflow-hidden relative h-32 w-32 cursor-pointer"
@click="source = file.pathname"
>
<img v-if="file.contentType?.startsWith('image/')" :src="`/api/blob/${file.pathname}`" class="h-32 w-32 object-cover">
</UCard>
</div>
<UAlert v-else title="You don't have any files yet." />
</div>
</UCard>
</template>
7 changes: 7 additions & 0 deletions playground/nuxt.config.ts
Original file line number Diff line number Diff line change
@@ -19,6 +19,13 @@ export default defineNuxtConfig({
browser: true,
kv: true,
cache: true,
image: {
trustedDomains: ['hub.nuxt.com'],
templates: {
small: { width: 128, height: 128, format: 'webp' },
medium: { width: 512, height: 512, format: 'webp' }
}
},
bindings: {
compatibilityDate: '2024-10-02',
compatibilityFlags: ['nodejs_compat']
140 changes: 77 additions & 63 deletions pnpm-lock.yaml
36 changes: 35 additions & 1 deletion src/features.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { execSync } from 'node:child_process'
import type { Nuxt } from '@nuxt/schema'
import { logger, addImportsDir, addServerImportsDir, addServerScanDir, createResolver } from '@nuxt/kit'
import { logger, addImportsDir, addServerImportsDir, addServerScanDir, createResolver, useNitro } from '@nuxt/kit'
import { joinURL } from 'ufo'
import { join } from 'pathe'
import { defu } from 'defu'
@@ -163,6 +163,40 @@ export function setupCache(nuxt: Nuxt) {
addServerScanDir(resolve('./runtime/cache/server'))
}

export function setupImage(nuxt: Nuxt) {
// Add Server scanning
addServerScanDir(resolve('./runtime/image/server'))

nuxt.options.nitro.externals = nuxt.options.nitro.externals || {}
nuxt.options.nitro.externals.inline = nuxt.options.nitro.externals.inline || []
nuxt.options.nitro.externals.inline.push('@cf-wasm/photon')

nuxt.hook('ready', () => {
const nitro = useNitro()
const _addWasmSupport = (_nitro: typeof nitro) => {
if (nitro.options.experimental?.wasm) {
return
}
_nitro.options.externals = _nitro.options.externals || {}
_nitro.options.externals.inline = _nitro.options.externals.inline || []
_nitro.options.externals.inline.push(id => id.endsWith('.wasm'))
_nitro.hooks.hook('rollup:before', async (_, rollupConfig) => {
const { rollup: unwasm } = await import('unwasm/plugin')
rollupConfig.plugins = rollupConfig.plugins || []
;(rollupConfig.plugins as any[]).push(
unwasm({
...(_nitro.options.wasm as any)
})
)
})
}
_addWasmSupport(nitro)
nitro.hooks.hook('prerender:init', (prerenderer) => {
_addWasmSupport(prerenderer)
})
})
}

export function setupDatabase(_nuxt: Nuxt) {
// Add Server scanning
addServerScanDir(resolve('./runtime/database/server'))
4 changes: 3 additions & 1 deletion src/module.ts
Original file line number Diff line number Diff line change
@@ -8,7 +8,7 @@
import type { Nuxt } from '@nuxt/schema'
import { version } from '../package.json'
import { generateWrangler } from './utils/wrangler'
import { setupAI, setupCache, setupAnalytics, setupBlob, setupBrowser, setupOpenAPI, setupDatabase, setupKV, setupBase, setupRemote } from './features'
import { setupAI, setupCache, setupAnalytics, setupBlob, setupBrowser, setupOpenAPI, setupDatabase, setupKV, setupBase, setupRemote, setupImage } from './features'
import type { ModuleOptions } from './types/module'
import { addBuildHooks } from './utils/build'

@@ -59,6 +59,7 @@
cache: false,
database: false,
kv: false,
image: false,
// Other options
version,
env: process.env.NUXT_HUB_ENV || 'production',
@@ -70,7 +71,7 @@
compatibilityFlags: nuxt.options.nitro.cloudflare?.wrangler?.compatibility_flags
}
})
runtimeConfig.hub = hub

Check failure on line 74 in src/module.ts

GitHub Actions / lint

Type 'Omit<Omit<{ projectUrl: string; projectSecretKey: string; url: string; projectKey: string; userToken: string; remote: any; remoteManifest: any; dir: string; ai: boolean; analytics: boolean; blob: boolean; ... 8 more ...; bindings: { ...; }; }, "database" | ... 14 more ... | "bindings"> & Omit<...> & { ...; }, "datab...' is not assignable to type '{ projectUrl: string; projectSecretKey: string; url: string; projectKey: string; userToken: string; remote: any; remoteManifest: any; dir: string; ai: boolean; analytics: boolean; blob: boolean; browser: boolean; ... 7 more ...; bindings: { ...; }; }'.

Check failure on line 74 in src/module.ts

GitHub Actions / lint

Type 'Omit<Omit<{ projectUrl: string; projectSecretKey: string; url: string; projectKey: string; userToken: string; remote: any; remoteManifest: any; dir: string; ai: boolean; analytics: boolean; blob: boolean; ... 8 more ...; bindings: { ...; }; }, "database" | ... 14 more ... | "bindings"> & Omit<...> & { ...; }, "datab...' is not assignable to type '{ projectUrl: string; projectSecretKey: string; url: string; projectKey: string; userToken: string; remote: any; remoteManifest: any; dir: string; ai: boolean; analytics: boolean; blob: boolean; browser: boolean; ... 7 more ...; bindings: { ...; }; }'.
// Make sure to tell Nitro to not generate the wrangler.toml file
// @ts-expect-error nitro.cloudflare.wrangler is not yet typed
delete nuxt.options.nitro.cloudflare?.wrangler?.compatibility_flags
@@ -99,22 +100,23 @@
})
}

setupBase(nuxt, hub)

Check failure on line 103 in src/module.ts

GitHub Actions / lint

Argument of type 'Omit<Omit<{ projectUrl: string; projectSecretKey: string; url: string; projectKey: string; userToken: string; remote: any; remoteManifest: any; dir: string; ai: boolean; analytics: boolean; blob: boolean; ... 8 more ...; bindings: { ...; }; }, "database" | ... 14 more ... | "bindings"> & Omit<...> & { ...; }, "datab...' is not assignable to parameter of type 'HubConfig'.

Check failure on line 103 in src/module.ts

GitHub Actions / lint

Argument of type 'Omit<Omit<{ projectUrl: string; projectSecretKey: string; url: string; projectKey: string; userToken: string; remote: any; remoteManifest: any; dir: string; ai: boolean; analytics: boolean; blob: boolean; ... 8 more ...; bindings: { ...; }; }, "database" | ... 14 more ... | "bindings"> & Omit<...> & { ...; }, "datab...' is not assignable to parameter of type 'HubConfig'.
setupOpenAPI(nuxt)
hub.ai && await setupAI(nuxt, hub)

Check failure on line 105 in src/module.ts

GitHub Actions / lint

Argument of type 'Omit<Omit<{ projectUrl: string; projectSecretKey: string; url: string; projectKey: string; userToken: string; remote: any; remoteManifest: any; dir: string; ai: boolean; analytics: boolean; blob: boolean; ... 8 more ...; bindings: { ...; }; }, "database" | ... 14 more ... | "bindings"> & Omit<...> & { ...; }, "datab...' is not assignable to parameter of type 'HubConfig'.

Check failure on line 105 in src/module.ts

GitHub Actions / lint

Argument of type 'Omit<Omit<{ projectUrl: string; projectSecretKey: string; url: string; projectKey: string; userToken: string; remote: any; remoteManifest: any; dir: string; ai: boolean; analytics: boolean; blob: boolean; ... 8 more ...; bindings: { ...; }; }, "database" | ... 14 more ... | "bindings"> & Omit<...> & { ...; }, "datab...' is not assignable to parameter of type 'HubConfig'.
hub.analytics && setupAnalytics(nuxt)
hub.blob && setupBlob(nuxt)
hub.browser && await setupBrowser(nuxt)
hub.cache && setupCache(nuxt)
hub.database && setupDatabase(nuxt)
hub.kv && setupKV(nuxt)
hub.image && setupImage(nuxt)

// nuxt prepare, stop here
if (nuxt.options._prepare) {
return
}

addBuildHooks(nuxt, hub)

Check failure on line 119 in src/module.ts

GitHub Actions / lint

Argument of type 'Omit<Omit<{ projectUrl: string; projectSecretKey: string; url: string; projectKey: string; userToken: string; remote: any; remoteManifest: any; dir: string; ai: boolean; analytics: boolean; blob: boolean; ... 8 more ...; bindings: { ...; }; }, "database" | ... 14 more ... | "bindings"> & Omit<...> & { ...; }, "datab...' is not assignable to parameter of type 'HubConfig'.

Check failure on line 119 in src/module.ts

GitHub Actions / lint

Argument of type 'Omit<Omit<{ projectUrl: string; projectSecretKey: string; url: string; projectKey: string; userToken: string; remote: any; remoteManifest: any; dir: string; ai: boolean; analytics: boolean; blob: boolean; ... 8 more ...; bindings: { ...; }; }, "database" | ... 14 more ... | "bindings"> & Omit<...> & { ...; }, "datab...' is not assignable to parameter of type 'HubConfig'.

// Fix cloudflare:* externals in rollup
nuxt.options.nitro.rollupConfig = nuxt.options.nitro.rollupConfig || {}
@@ -138,7 +140,7 @@
}

if (hub.remote) {
await setupRemote(nuxt, hub)

Check failure on line 143 in src/module.ts

GitHub Actions / lint

Argument of type 'Omit<Omit<{ projectUrl: string; projectSecretKey: string; url: string; projectKey: string; userToken: string; remote: any; remoteManifest: any; dir: string; ai: boolean; analytics: boolean; blob: boolean; ... 8 more ...; bindings: { ...; }; }, "database" | ... 14 more ... | "bindings"> & Omit<...> & { ...; }, "datab...' is not assignable to parameter of type 'HubConfig'.
return
}

@@ -202,7 +204,7 @@
if (needWrangler) {
// Generate the wrangler.toml file
const wranglerPath = join(hubDir, './wrangler.toml')
await writeFile(wranglerPath, generateWrangler(nuxt, hub), 'utf-8')

Check failure on line 207 in src/module.ts

GitHub Actions / lint

Argument of type 'Omit<Omit<{ projectUrl: string; projectSecretKey: string; url: string; projectKey: string; userToken: string; remote: any; remoteManifest: any; dir: string; ai: boolean; analytics: boolean; blob: boolean; ... 8 more ...; bindings: { ...; }; }, "database" | ... 14 more ... | "bindings"> & Omit<...> & { ...; }, "datab...' is not assignable to parameter of type 'HubConfig'.
// @ts-expect-error cloudflareDev is not typed here
nuxt.options.nitro.cloudflareDev = {
persistDir: hubDir,
125 changes: 125 additions & 0 deletions src/runtime/image/server/routes/_hub/image/[...params].get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { createError, eventHandler, getValidatedRouterParams, setHeader } from 'h3'
import { z } from 'zod'
import { PhotonImage, SamplingFilter, resize, rotate, initAsync } from '@cf-wasm/photon/next'
import photonWasmModule from '@cf-wasm/photon/photon.wasm?module'
import { hubBlob } from '../../../../../blob/server/utils/blob'
import { useRuntimeConfig } from '#imports'

export default eventHandler(async (event) => {
const { image: imageOptions } = useRuntimeConfig().hub
const trustedDomains = imageOptions?.trustedDomains || []
const templates = imageOptions?.templates || {}
const { params } = await getValidatedRouterParams(event, z.object({
// match <OPTIONS>/<SOURCE-IMAGE>
params: z.string().regex(/^\/?[^/]*\/.+$/)
}).parse)

await initAsync(photonWasmModule)

const [template, ...sourceParts] = params.split('/')
let templateOptions = templates[template]

if (import.meta.dev && !templateOptions) {
console.warn(`[NuxtHub] Image template "${template}" not found in config, trying to parse option from URL. This is only supported in development mode.`)
templateOptions = parseOptions(template)
}

if (!templateOptions) {
throw createError({ statusCode: 400, message: 'Invalid image transformation' })
}

// create a PhotonImage instance
const inputBytes = await getImageBytes(sourceParts.join('/'), trustedDomains)
const inputImage = PhotonImage.new_from_byteslice(inputBytes)

let image: PhotonImage = resizeImage(inputImage, templateOptions)

if (templateOptions.rotate) {
const _image = image
// rotate image using photon
image = rotate(
_image,
Number(templateOptions.rotate)
)

_image.free()
}

const { format = 'webp' } = templateOptions
let outputBytes
switch (format) {
case 'png':
outputBytes = image.get_bytes()
break
case 'jpeg':
outputBytes = image.get_bytes_jpeg(templateOptions.jpeg_quality || 80)
break
case 'webp':
default:
outputBytes = image.get_bytes_webp()
}

// call free() method to free memory
image.free()

setHeader(event, 'Cache-Control', 'public, max-age=31536000, immutable')
setHeader(event, 'Content-Length', outputBytes.byteLength)
setHeader(event, 'Content-Type', `image/${format || 'webp'}`)
return outputBytes
})

function resizeImage(image: PhotonImage, templateOptions: Record<string, string>) {
if (templateOptions.width || templateOptions.height) {
const imageWidth = image.get_width()
const imageHeight = image.get_height()
const imageRatio = imageWidth / imageHeight
const width = templateOptions.width ? Number(templateOptions.width) : Number(templateOptions.height) * imageRatio
const height = templateOptions.height ? Number(templateOptions.height) : Number(templateOptions.width) / imageRatio

const _image = image
// resize image using photon
image = resize(
_image,
width,
height,
SamplingFilter.Lanczos3
)

_image.free()
}

return image
}

function parseOptions(options: string) {
const templateOptions: Record<string, string> = {}
options.split(',').forEach((option) => {
const [key, value] = option.split('=')
templateOptions[key] = value
})
return templateOptions
}

async function getImageBytes(source: string, trustedDomains: string[]) {
if (source.startsWith('http')) {
const hostname = new URL(source).hostname

if (!trustedDomains.includes(hostname)) {
if (import.meta.dev) {
console.warn(
`[NuxtHub] Retriving image from "${hostname}" is not allowed in production mode, consider adding "${hostname}" to trustedDomains in your config.`
)
} else {
throw createError({ statusCode: 403, message: 'Image not allowed' })
}
}

return await fetch(source)
.then(res => res.arrayBuffer())
.then(buffer => new Uint8Array(buffer))
}

return hubBlob().get(source)
.then(blob => blob!.arrayBuffer())
.then(buffer => new Uint8Array(buffer))
}
3 changes: 3 additions & 0 deletions src/runtime/image/server/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": "../../../../.nuxt/tsconfig.server.json",
}
18 changes: 18 additions & 0 deletions src/types/module.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
interface ImageTemplate {
width?: number
height?: number
format?: string
rotate?: number
jpeg_quality?: number
}

export interface ModuleOptions {
/**
* Set `true` to enable AI for the project.
@@ -49,6 +57,16 @@ export interface ModuleOptions {
* @see https://hub.nuxt.com/docs/features/kv
*/
kv?: boolean
/**
* Set `true` to enable the Image transformation for the project.
*
* @default false
* @see https://hub.nuxt.com/docs/features/image
*/
image?: false | {
trustedDomains?: string[]
templates: Record<string, ImageTemplate>
}
/**
* Set to `true`, 'preview' or 'production' to use the remote storage.
* Only set the value on a project you are deploying outside of NuxtHub or Cloudflare.