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
30 changes: 29 additions & 1 deletion docs/content/docs/3.blob/1.index.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,35 @@ Available drivers:
}
})
```
:::
:::
::

## Docker/Kubernetes Deployments

Build container images without blob credentials by deferring S3 environment resolution to runtime.

```ts [nuxt.config.ts]
export default defineNuxtConfig({
hub: {
blob: {
driver: 's3'
}
}
})
```

Set the credentials when the container starts:

```bash [.env]
S3_ACCESS_KEY_ID=your-access-key-id
S3_SECRET_ACCESS_KEY=your-secret-access-key
S3_BUCKET=your-bucket-name
S3_REGION=your-region
S3_ENDPOINT=your-endpoint # optional
```

::note
Using `blob: true` also supports runtime detection in production Node deployments. NuxtHub checks `S3_*` first, then `BLOB_READ_WRITE_TOKEN`, and falls back to the filesystem driver when neither is present.
::


Expand Down
4 changes: 4 additions & 0 deletions docs/content/docs/7.reference/1.environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ These variables are required when using the `d1-http` driver to access Cloudflar
| `S3_REGION` | AWS region (use `auto` for R2) | Yes |
| `S3_ENDPOINT` | Custom endpoint URL (required for R2) | R2 only |

::note
For production Node deployments, NuxtHub can resolve these variables at runtime when you use `hub: { blob: true }` or `hub: { blob: { driver: 's3' } }`.
::

### Vercel Blob

| Variable | Description |
Expand Down
209 changes: 172 additions & 37 deletions src/blob/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,108 +4,236 @@ import { defu } from 'defu'
import { addTypeTemplate, addServerImports, addImportsDir, logger } from '@nuxt/kit'

import type { Nuxt } from '@nuxt/schema'
import type { HubConfig, ResolvedBlobConfig, CloudflareR2BlobConfig } from '@nuxthub/core'
import type { CloudflareR2BlobConfig, HubConfig, ResolvedBlobConfig, ResolvedS3BlobConfig, S3BlobConfig } from '@nuxthub/core'
import { resolve, logWhenReady, addWranglerBinding } from '../utils'

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

// Supported blob drivers
const supportedDrivers = ['fs', 's3', 'vercel-blob', 'cloudflare-r2'] as const

/**
* Resolve blob configuration from boolean or object format
*/
function isBlobConfigObject(blob: HubConfig['blob']): blob is Exclude<HubConfig['blob'], boolean> {
return typeof blob === 'object' && blob !== null
}

function resolveS3BlobConfig(blobConfig?: Partial<S3BlobConfig>): ResolvedS3BlobConfig {
return {
driver: 's3',
accessKeyId: blobConfig?.accessKeyId || process.env.S3_ACCESS_KEY_ID || '',
secretAccessKey: blobConfig?.secretAccessKey || process.env.S3_SECRET_ACCESS_KEY || '',
bucket: blobConfig?.bucket || process.env.S3_BUCKET || '',
region: blobConfig?.region || process.env.S3_REGION || 'auto',
endpoint: blobConfig?.endpoint || process.env.S3_ENDPOINT
}
}

function isConfiguredS3BlobConfig(blobConfig: Pick<ResolvedS3BlobConfig, 'accessKeyId' | 'secretAccessKey' | 'bucket' | 'endpoint'>) {
return Boolean(blobConfig.accessKeyId && blobConfig.secretAccessKey && (blobConfig.bucket || blobConfig.endpoint))
}

function shouldResolveBlobAtRuntime(nuxt: Nuxt, hub: HubConfig, blobInput: HubConfig['blob'], blobConfig: ResolvedBlobConfig) {
if (nuxt.options.dev || hub.hosting.includes('cloudflare')) {
return false
}

if (blobInput === true && blobConfig.driver === 'fs') {
return 'auto' as const
}

if (isBlobConfigObject(blobInput) && blobInput.driver === 's3' && !isConfiguredS3BlobConfig(blobInput)) {
return 's3' as const
}

return false
}

export function generateBlobContent(nuxt: Nuxt, hub: HubConfig, blobInput: HubConfig['blob'], blobConfig: ResolvedBlobConfig): string {
const runtimeResolution = shouldResolveBlobAtRuntime(nuxt, hub, blobInput, blobConfig)

if (runtimeResolution === 'auto') {
return `import { createBlobStorage } from "@nuxthub/core/blob";

export { ensureBlob } from "@nuxthub/core/blob";

function resolveBlobConfig() {
if (process.env.S3_ACCESS_KEY_ID && process.env.S3_SECRET_ACCESS_KEY && (process.env.S3_BUCKET || process.env.S3_ENDPOINT)) {
return {
driver: "s3",
accessKeyId: process.env.S3_ACCESS_KEY_ID,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
bucket: process.env.S3_BUCKET || "",
region: process.env.S3_REGION || "auto",
endpoint: process.env.S3_ENDPOINT
}
}

if (process.env.BLOB_READ_WRITE_TOKEN) {
return {
driver: "vercel-blob",
access: "public"
}
}

return ${JSON.stringify(blobConfig)}
}

const blobConfig = resolveBlobConfig()

const { createDriver } = await (async () => {
if (blobConfig.driver === "s3") {
return import("@nuxthub/core/blob/drivers/s3")
}

if (blobConfig.driver === "vercel-blob") {
return import("@nuxthub/core/blob/drivers/vercel-blob")
}

return import("@nuxthub/core/blob/drivers/fs")
})()

const { driver, ...driverOptions } = blobConfig
export const blob = createBlobStorage(createDriver(driverOptions));
`
}

if (runtimeResolution === 's3') {
const s3Config = blobInput as S3BlobConfig

return `import { createBlobStorage } from "@nuxthub/core/blob";

export { ensureBlob } from "@nuxthub/core/blob";

const blobConfig = {
driver: "s3",
accessKeyId: ${JSON.stringify(s3Config.accessKeyId)} || process.env.S3_ACCESS_KEY_ID || "",
secretAccessKey: ${JSON.stringify(s3Config.secretAccessKey)} || process.env.S3_SECRET_ACCESS_KEY || "",
bucket: ${JSON.stringify(s3Config.bucket)} || process.env.S3_BUCKET || "",
region: ${JSON.stringify(s3Config.region)} || process.env.S3_REGION || "auto",
endpoint: ${JSON.stringify(s3Config.endpoint)} || process.env.S3_ENDPOINT
}

if (!blobConfig.accessKeyId || !blobConfig.secretAccessKey || (!blobConfig.bucket && !blobConfig.endpoint)) {
throw new Error('[nuxt-hub] S3 blob driver requires \`accessKeyId\`, \`secretAccessKey\`, and \`bucket\` or \`endpoint\` options, or the matching S3_* environment variables.')
}

const { createDriver } = await import("@nuxthub/core/blob/drivers/s3")

export const blob = createBlobStorage(createDriver(blobConfig));
`
}

const { driver, bucketName: _bucketName, ...driverOptions } = blobConfig as CloudflareR2BlobConfig

return `import { createBlobStorage } from "@nuxthub/core/blob";
import { createDriver } from "@nuxthub/core/blob/drivers/${driver}";

export { ensureBlob } from "@nuxthub/core/blob";
export const blob = createBlobStorage(createDriver(${JSON.stringify(driverOptions)}));
`
}

export function resolveBlobConfig(hub: HubConfig, deps: Record<string, string>): ResolvedBlobConfig | false {
if (!hub.blob) return false

// If driver is already specified by user, use it with their options
if (typeof hub.blob === 'object' && 'driver' in hub.blob) {
if (isBlobConfigObject(hub.blob) && 'driver' in hub.blob) {
if (hub.blob.driver === 's3') {
if ((hub.blob.accessKeyId || process.env.S3_ACCESS_KEY_ID) && !deps['aws4fetch']) {
log.error('Please run `npx nypm i aws4fetch` to use S3')
}

return resolveS3BlobConfig(hub.blob)
}

if (hub.blob.driver === 'vercel-blob') {
if (!deps['@vercel/blob']) {
log.error('Please run `npx nypm i @vercel/blob` to use Vercel Blob')
}

return defu(hub.blob, {
access: 'public'
}) as ResolvedBlobConfig
}

if (hub.blob.driver === 'cloudflare-r2') {
return defu(hub.blob, {
binding: 'BLOB'
}) as ResolvedBlobConfig
}

if (hub.blob.driver === 'fs') {
return defu(hub.blob, {
dir: join(hub.dir, 'blob')
}) as ResolvedBlobConfig
}

return hub.blob as ResolvedBlobConfig
}

// Otherwise hub.blob is set to true, so we need to resolve the config
// AWS S3
if (process.env.S3_ACCESS_KEY_ID && process.env.S3_SECRET_ACCESS_KEY && (process.env.S3_BUCKET || process.env.S3_ENDPOINT)) {
const s3Config = resolveS3BlobConfig()
if (isConfiguredS3BlobConfig(s3Config)) {
if (!deps['aws4fetch']) {
log.error('Please run `npx nypm i aws4fetch` to use S3')
}
return defu(hub.blob, {
driver: 's3',
accessKeyId: process.env.S3_ACCESS_KEY_ID,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
bucket: process.env.S3_BUCKET || '',
region: process.env.S3_REGION || 'auto',
endpoint: process.env.S3_ENDPOINT
}) as ResolvedBlobConfig

return s3Config
}

// Vercel Blob
if (hub.hosting.includes('vercel') || process.env.BLOB_READ_WRITE_TOKEN) {
if (!deps['@vercel/blob']) {
log.error('Please run `npx nypm i @vercel/blob` to use Vercel Blob')
}

return defu(hub.blob, {
driver: 'vercel-blob',
access: 'public'
}) as ResolvedBlobConfig
}

// Cloudflare R2
if (hub.hosting.includes('cloudflare')) {
return defu(hub.blob, {
driver: 'cloudflare-r2',
binding: 'BLOB'
}) as ResolvedBlobConfig
}

// Default: file system storage
return defu(hub.blob, {
driver: 'fs',
dir: join(hub.dir, 'blob')
}) as ResolvedBlobConfig
}

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

const blobConfig = hub.blob as ResolvedBlobConfig
const runtimeResolution = shouldResolveBlobAtRuntime(nuxt, hub, blobInput, blobConfig)

if (blobConfig.driver === 'cloudflare-r2' && blobConfig.bucketName) {
addWranglerBinding(nuxt, 'r2_buckets', { binding: blobConfig.binding || 'BLOB', bucket_name: blobConfig.bucketName })
}

// Add Composables
addImportsDir(resolve('blob/runtime/app/composables'))

const { driver, bucketName: _bucketName, ...driverOptions } = blobConfig as CloudflareR2BlobConfig
const { driver } = blobConfig

if (!supportedDrivers.includes(driver as any)) {
log.error(`Unsupported blob driver: ${driver}. Supported drivers: ${supportedDrivers.join(', ')}`)
return
}

// Generate physical @nuxthub/blob package for external bundlers (like Workflow)
const blobContent = `import { createBlobStorage } from "@nuxthub/core/blob";
import { createDriver } from "@nuxthub/core/blob/drivers/${driver}";

export { ensureBlob } from "@nuxthub/core/blob";
export const blob = createBlobStorage(createDriver(${JSON.stringify(driverOptions)}));
`
const blobContent = generateBlobContent(nuxt, hub, blobInput, blobConfig)

const physicalBlobDir = join(nuxt.options.rootDir, 'node_modules', '@nuxthub', 'blob')
await mkdir(physicalBlobDir, { recursive: true })

// Write the blob.mjs file
await writeFile(join(physicalBlobDir, 'blob.mjs'), blobContent)

// Copy blob.d.ts for TypeScript support
await copyFile(
resolve('blob/runtime/blob.d.ts'),
join(physicalBlobDir, 'blob.d.ts')
)

// Write package.json for the physical package
const packageJson = {
name: '@nuxthub/blob',
version: '0.0.0',
Expand All @@ -123,26 +251,33 @@ export const blob = createBlobStorage(createDriver(${JSON.stringify(driverOption
}
await writeFile(join(physicalBlobDir, 'package.json'), JSON.stringify(packageJson, null, 2))

// Add alias to map hub:blob to @nuxthub/blob
nuxt.options.alias!['hub:blob'] = '@nuxthub/blob'

addServerImports({ name: 'blob', from: '@nuxthub/blob', meta: { description: `The Blob storage instance.` } })
addServerImports({ name: 'ensureBlob', from: '@nuxthub/blob', meta: { description: `Ensure the blob is valid and meets the specified requirements.` } })

// Generate type declaration for hub:blob virtual module
addTypeTemplate({
filename: 'hub/blob.d.ts',
getContents: () => `declare module 'hub:blob' {
export * from '@nuxthub/blob'
}`
}, { nitro: true, nuxt: true })

// Set blob provider in runtime config for client-side composables
if (blobConfig.driver === 'vercel-blob') {
if (blobConfig.driver === 'vercel-blob' && runtimeResolution !== 'auto') {
nuxt.options.runtimeConfig.public.hub ||= {}
nuxt.options.runtimeConfig.public.hub.blobProvider = 'vercel-blob'
logWhenReady(nuxt, 'Files stored in Vercel Blob are public. Manually configure a different storage driver if storing sensitive files.', 'warn')
}

if (runtimeResolution === 'auto') {
logWhenReady(nuxt, '`hub:blob` using runtime driver resolution')
return
}

if (runtimeResolution === 's3') {
logWhenReady(nuxt, '`hub:blob` using `s3` driver with runtime env resolution')
return
}

logWhenReady(nuxt, `\`hub:blob\` using \`${blobConfig.driver}\` driver`)
}
5 changes: 3 additions & 2 deletions src/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,13 @@ export interface ModuleOptions {

// Blob driver configurations - extend from driver option types
export type FSBlobConfig = { driver: 'fs' } & FSDriverOptions
export type S3BlobConfig = { driver: 's3' } & S3DriverOptions
export type S3BlobConfig = { driver: 's3' } & Partial<S3DriverOptions>
export type ResolvedS3BlobConfig = { driver: 's3' } & S3DriverOptions
export type VercelBlobConfig = { driver: 'vercel-blob' } & VercelDriverOptions
export type CloudflareR2BlobConfig = { driver: 'cloudflare-r2', bucketName?: string } & CloudflareDriverOptions

export type BlobConfig = boolean | FSBlobConfig | S3BlobConfig | VercelBlobConfig | CloudflareR2BlobConfig
export type ResolvedBlobConfig = FSBlobConfig | S3BlobConfig | VercelBlobConfig | CloudflareR2BlobConfig
export type ResolvedBlobConfig = FSBlobConfig | ResolvedS3BlobConfig | VercelBlobConfig | CloudflareR2BlobConfig

export type CacheConfig = {
driver?: BuiltinDriverName
Expand Down
Loading
Loading