diff --git a/build.config.ts b/build.config.ts index e7aa418a3..302ff94ab 100644 --- a/build.config.ts +++ b/build.config.ts @@ -50,6 +50,12 @@ export default defineBuildConfig({ input: 'src/cache/runtime/', outDir: 'dist/cache/runtime', builder: 'mkdist' + }, + // Remote (cloudflare dev bindings) + { + input: 'src/remote/runtime/', + outDir: 'dist/remote/runtime', + builder: 'mkdist' } ] }) diff --git a/docs/content/docs/1.getting-started/3.deploy.md b/docs/content/docs/1.getting-started/3.deploy.md index 2330c772f..6c6961dbd 100644 --- a/docs/content/docs/1.getting-started/3.deploy.md +++ b/docs/content/docs/1.getting-started/3.deploy.md @@ -4,6 +4,8 @@ navigation.title: Deploy description: Learn how to host a full-stack Nuxt application with minimal configuration. --- +NuxtHub supports multiple cloud providers including Vercel, Cloudflare Workers, and any Node.js hosting platform. Each provider offers different storage options that NuxtHub automatically configures. + ## Vercel ::tip @@ -56,6 +58,12 @@ export default defineNuxtConfig({ }) ``` +::important{title="Remote Bindings in Development"} +When you configure a Cloudflare binding ID (`databaseId`, `namespaceId`, `bucketName`, or `hyperdriveId`), NuxtHub automatically connects to your remote Cloudflare resources during local development. This uses Wrangler's [`getPlatformProxy`](https://developers.cloudflare.com/workers/wrangler/api/#getplatformproxy) to provide real bindings in dev mode. + +Ensure `wrangler` is installed as a dev dependency: `npx nypm add -D wrangler` +:: + ::tip See a working example at [onmax/repros/nuxthub-716](https://github.com/onmax/repros/tree/main/nuxthub-716) — deployed without a `wrangler.toml` file. :: @@ -84,6 +92,46 @@ During the build process, NuxtHub resolves the target environment using the foll 3. Generates `.output/server/wrangler.json` with the resolved bindings 4. Wrangler uses this configuration during deployment +### Local-Only Development + +If you prefer to use local storage during development and only connect to Cloudflare resources in production, use Nuxt's `$production` environment override. This pattern ensures binding IDs are only applied when building for production: + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + hub: { + db: 'sqlite', + kv: true, + blob: true + }, + // Only use remote bindings in production + $production: { + hub: { + db: { + dialect: 'sqlite', + driver: 'd1', + connection: { databaseId: '' } + }, + kv: { + driver: 'cloudflare-kv-binding', + namespaceId: '' + }, + blob: { + driver: 'cloudflare-r2', + bucketName: '' + } + } + } +}) +``` + +With this configuration: +- **Development**: Uses local storage (SQLite file, filesystem) in `.data/` directory +- **Production**: Connects to your Cloudflare D1, KV, and R2 resources + +::note +The `$production` override merges with your base config. You only need to specify the properties that differ in production. +:: + ### Deploy Create a [Cloudflare Workers project](https://dash.cloudflare.com/?to=/:account/workers-and-pages/create) and link your GitHub or GitLab repository. NuxtHub auto-configures bindings from your `nuxt.config.ts` during build. diff --git a/docs/content/docs/1.getting-started/4.migration.md b/docs/content/docs/1.getting-started/4.migration.md index 490ec229b..486a814f1 100644 --- a/docs/content/docs/1.getting-started/4.migration.md +++ b/docs/content/docs/1.getting-started/4.migration.md @@ -33,6 +33,7 @@ You can visit [legacy.hub.nuxt.com](https://legacy.hub.nuxt.com) to read the doc | Blob access | `hubBlob()` | `blob` from `hub:blob` | | KV access | `hubKV()` | `kv` from `hub:kv` | | AI & AutoRAG | `hubAI()` | Removed (use [AI SDK](https://ai-sdk.dev)) | +| Remote storage | `hub.projectUrl` + `hub.projectSecretKey` | Auto-enabled when binding IDs configured | | NuxtHub Admin | Supported | Deprecated (sunset Dec 31, 2025) | | `nuxthub deploy` | Supported | Deprecated (sunset Jan 31, 2026) | @@ -93,6 +94,40 @@ export default defineNuxtConfig({ }) ``` +### Remote Development + +The `projectUrl` and `projectSecretKey` options have been removed. NuxtHub now auto-detects when you want to connect to remote Cloudflare bindings during local development. + +When binding IDs are present in your config (e.g., `databaseId`, `namespaceId`, `bucketName`), NuxtHub will: +1. Detect the remote binding configuration +2. Show a warning about connecting to production resources +3. Generate a temporary `wrangler.toml` with `remote = true` for all bindings + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + hub: { + db: { + dialect: 'sqlite', + driver: 'd1', + connection: { databaseId: '' } // Triggers remote binding + }, + kv: { driver: 'cloudflare-kv-binding', namespaceId: '' }, + blob: { driver: 'cloudflare-r2', bucketName: '' } + } +}) +``` + +::warning +Remote bindings connect to **production resources**. Seeds and migrations will run against production data. Confirm only when intentional. +:: + +::tip +Use `$production` pattern to only enable binding IDs in production, keeping local development isolated: +```ts +connection: { databaseId: { $production: '' } } +``` +:: + ## Code Migration ### Database Access @@ -384,6 +419,7 @@ Replace `npx nuxthub deploy` with your provider's deployment method: - [ ] Replace `hubBlob()` calls with `blob` from `hub:blob` - [ ] Replace `hubKV()` calls with `kv` from `hub:kv` - [ ] Remove AI/AutoRAG usage or migrate to AI SDK +- [ ] Remove `hub.projectUrl` and `hub.projectSecretKey` (remote bindings auto-enable when IDs configured) - [ ] For Cloudflare: Configure resource IDs in `nuxt.config.ts` (v0.10.3+) OR create manual `wrangler.jsonc` - [ ] For Vercel: Add storage from dashboard and install required packages - [ ] Update CI/CD from NuxtHub GitHub Action to provider's deployment (Workers/Pages CI, Vercel Git integration, etc.) diff --git a/docs/content/docs/2.database/1.index.md b/docs/content/docs/2.database/1.index.md index 20b26216e..ef5434fc6 100644 --- a/docs/content/docs/2.database/1.index.md +++ b/docs/content/docs/2.database/1.index.md @@ -22,6 +22,9 @@ Install Drizzle ORM, Drizzle Kit, and the appropriate driver(s) for the database - Uses `postgres-js` driver if you set `DATABASE_URL`, `POSTGRES_URL`, or `POSTGRESQL_URL` environment variable. - Use `neon-http` driver with `@neondatabase/serverless` for [Neon](https://neon.com) serverless PostgreSQL. :: + ::note + Setting a `DATABASE_URL` locally connects to your remote PostgreSQL database during development. + :: ::: :::tabs-item{label="MySQL" icon="i-simple-icons-mysql"} :pm-install{name="drizzle-orm drizzle-kit mysql2"} @@ -30,6 +33,9 @@ Install Drizzle ORM, Drizzle Kit, and the appropriate driver(s) for the database - Uses `mysql2` driver if you set `DATABASE_URL` or `MYSQL_URL` environment variable. - Requires environment variable (no local fallback). :: + ::note + Setting a `DATABASE_URL` or `MYSQL_URL` locally connects to your remote MySQL database during development. + :: ::: :::tabs-item{label="SQLite" icon="i-simple-icons-sqlite"} :pm-install{name="drizzle-orm drizzle-kit @libsql/client"} @@ -38,9 +44,15 @@ Install Drizzle ORM, Drizzle Kit, and the appropriate driver(s) for the database - Uses `libsql` driver for [Turso](https://turso.tech) if you set `TURSO_DATABASE_URL` and `TURSO_AUTH_TOKEN` environment variables. - Uses `libsql` locally with file at `.data/db/sqlite.db` if no environment variables are set. :: + ::note + Setting `TURSO_DATABASE_URL` locally connects to your remote Turso database during development. + :: ::tip{to="/docs/getting-started/deploy#cloudflare"} For Cloudflare D1, configure the database ID in your `nuxt.config.ts` and NuxtHub auto-generates the wrangler bindings. :: + ::warning + Configuring a `databaseId` connects to your remote Cloudflare D1 database during development. Use the [`$production` pattern](/docs/getting-started/deploy#local-only-development) to keep development local-only. + :: ::: :: diff --git a/docs/content/docs/3.blob/1.index.md b/docs/content/docs/3.blob/1.index.md index 9f9f3ef53..452e1a569 100644 --- a/docs/content/docs/3.blob/1.index.md +++ b/docs/content/docs/3.blob/1.index.md @@ -66,6 +66,10 @@ By default, if NuxtHub cannot detect a driver, files are stored locally in the ` S3_REGION=your-region S3_ENDPOINT=your-endpoint # (optional) ``` + + ::note + Setting these credentials locally connects to your remote S3 bucket during development. + :: ::: :::tabs-item{label="Vercel Blob" icon="i-simple-icons-vercel"} @@ -86,6 +90,10 @@ By default, if NuxtHub cannot detect a driver, files are stored locally in the ` ```bash [.env] BLOB_READ_WRITE_TOKEN=your-token ``` + + ::note + Setting this token locally connects to your remote Vercel Blob store during development. + :: ::: :::tabs-item{label="Cloudflare R2" icon="i-simple-icons-cloudflare"} @@ -107,6 +115,10 @@ By default, if NuxtHub cannot detect a driver, files are stored locally in the ` Learn more about R2 bindings on Cloudflare's documentation. :: + ::warning + Configuring a `bucketName` connects to your remote Cloudflare R2 bucket during development. Use the [`$production` pattern](/docs/getting-started/deploy#local-only-development) to keep development local-only. + :: + ::note To use Cloudflare R2 without hosting on Cloudflare Workers, use the [Cloudflare R2 via S3 API](https://developers.cloudflare.com/r2/api/s3/api/). :: diff --git a/docs/content/docs/4.kv/1.index.md b/docs/content/docs/4.kv/1.index.md index ed62c8644..4f235d2a4 100644 --- a/docs/content/docs/4.kv/1.index.md +++ b/docs/content/docs/4.kv/1.index.md @@ -35,6 +35,10 @@ When building the Nuxt app, NuxtHub automatically configures the key-value stora UPSTASH_REDIS_REST_TOKEN=... ``` + ::note + Setting these credentials locally connects to your remote Upstash Redis during development. + :: + ::tip When deploying to Vercel, we automatically detect if `KV_REST_API_URL` and `KV_REST_API_TOKEN` environment variables are set, and use them to configure the Upstash Redis connection. :: @@ -73,6 +77,10 @@ When building the Nuxt app, NuxtHub automatically configures the key-value stora Learn more about KV bindings on Cloudflare's documentation. :: + ::warning + Configuring a `namespaceId` connects to your remote Cloudflare KV namespace during development. Use the [`$production` pattern](/docs/getting-started/deploy#local-only-development) to keep development local-only. + :: + ::: :::tabs-item{label="Deno KV" icon="i-simple-icons-deno"} diff --git a/docs/content/docs/5.cache/1.index.md b/docs/content/docs/5.cache/1.index.md index 4bad8ea32..19ed3880b 100644 --- a/docs/content/docs/5.cache/1.index.md +++ b/docs/content/docs/5.cache/1.index.md @@ -98,6 +98,10 @@ NuxtHub automatically configures the cache storage driver based on your hosting ::callout{to="https://developers.cloudflare.com/kv/concepts/kv-bindings/"} Learn more about KV bindings on Cloudflare's documentation. :: + + ::warning + Configuring a `namespaceId` connects to your remote Cloudflare KV namespace during development. Use the [`$production` pattern](/docs/getting-started/deploy#local-only-development) to keep development local-only. + :: ::: :::tabs-item{label="Other" icon="i-simple-icons-nodedotjs" class="p-4"} diff --git a/src/blob/setup.ts b/src/blob/setup.ts index a4a1b93ae..b87881c8e 100644 --- a/src/blob/setup.ts +++ b/src/blob/setup.ts @@ -50,8 +50,9 @@ export function resolveBlobConfig(hub: HubConfig, deps: Record): }) as ResolvedBlobConfig } - // Cloudflare R2 - if (hub.hosting.includes('cloudflare')) { + // Cloudflare R2 (production or dev with bucketName) + const blobConfig = typeof hub.blob === 'object' ? hub.blob : {} + if (hub.hosting.includes('cloudflare') || ('bucketName' in blobConfig && blobConfig.bucketName)) { return defu(hub.blob, { driver: 'cloudflare-r2', binding: 'BLOB' diff --git a/src/cache/setup.ts b/src/cache/setup.ts index f920b698f..28c568824 100644 --- a/src/cache/setup.ts +++ b/src/cache/setup.ts @@ -22,8 +22,8 @@ export function resolveCacheConfig(hub: HubConfig): ResolvedCacheConfig | false return userConfig as ResolvedCacheConfig } - // Cloudflare KV cache binding - if (hub.hosting.includes('cloudflare')) { + // Cloudflare KV cache binding (production or dev with namespaceId) + if (hub.hosting.includes('cloudflare') || userConfig.namespaceId) { return defu(userConfig, { driver: 'cloudflare-kv-binding', binding: 'CACHE' diff --git a/src/db/setup.ts b/src/db/setup.ts index dedd9a83f..f43f45e51 100644 --- a/src/db/setup.ts +++ b/src/db/setup.ts @@ -80,8 +80,8 @@ export async function resolveDatabaseConfig(nuxt: Nuxt, hub: HubConfig): Promise if (config.driver === 'd1') { break } - // Cloudflare D1 (production only - dev uses local libsql by default) - if (hub.hosting.includes('cloudflare') && !nuxt.options.dev) { + // Cloudflare D1 (production, or dev with explicit databaseId) + if ((hub.hosting.includes('cloudflare') && !nuxt.options.dev && !nuxt.options._prepare) || config.connection?.databaseId) { config.driver = 'd1' break } @@ -90,19 +90,14 @@ export async function resolveDatabaseConfig(nuxt: Nuxt, hub: HubConfig): Promise config.connection = defu(config.connection, { url: '' }) break } - // Cloudflare D1 (production only - dev/prepare uses local libsql) - if (hub.hosting.includes('cloudflare') && !nuxt.options.dev && !nuxt.options._prepare) { - config.driver = 'd1' - break - } config.driver ||= 'libsql' config.connection = defu(config.connection, { url: `file:${join(hub.dir!, 'db/sqlite.db')}` }) await mkdir(join(hub.dir, 'db'), { recursive: true }) break } case 'postgresql': { - // Cloudflare Hyperdrive with explicit hyperdriveId - if (hub.hosting.includes('cloudflare') && config.connection?.hyperdriveId && !config.driver) { + // Cloudflare Hyperdrive with explicit hyperdriveId (production or dev) + if (config.connection?.hyperdriveId && !config.driver) { config.driver = 'postgres-js' break } @@ -121,8 +116,8 @@ export async function resolveDatabaseConfig(nuxt: Nuxt, hub: HubConfig): Promise break } case 'mysql': { - // Cloudflare Hyperdrive with explicit hyperdriveId - if (hub.hosting.includes('cloudflare') && config.connection?.hyperdriveId && !config.driver) { + // Cloudflare Hyperdrive with explicit hyperdriveId (production or dev) + if (config.connection?.hyperdriveId && !config.driver) { config.driver = 'mysql2' break } @@ -518,7 +513,8 @@ const db = drizzle(d1HttpDriver, { schema${casingOption} }) export { db, schema } ` } - if (['postgres-js', 'mysql2'].includes(driver) && hub.hosting.includes('cloudflare') && connection?.hyperdriveId) { + // Hyperdrive requires lazy binding access - bindings only available in request context on CF Workers + if (['postgres-js', 'mysql2'].includes(driver) && (hub.hosting.includes('cloudflare') || connection?.hyperdriveId)) { const bindingName = driver === 'postgres-js' ? 'POSTGRES' : 'MYSQL' drizzleOrmContent = generateLazyDbTemplate( `import { drizzle } from 'drizzle-orm/${driver}'`, diff --git a/src/kv/setup.ts b/src/kv/setup.ts index 6e253fbea..d88de4c2a 100644 --- a/src/kv/setup.ts +++ b/src/kv/setup.ts @@ -48,8 +48,9 @@ export function resolveKVConfig(hub: HubConfig): ResolvedKVConfig | false { }) as ResolvedKVConfig } - // Cloudflare KV - if (hub.hosting.includes('cloudflare')) { + // Cloudflare KV (production or dev with namespaceId) + const kvConfig = typeof hub.kv === 'object' ? hub.kv : {} + if (hub.hosting.includes('cloudflare') || kvConfig.namespaceId) { return defu(hub.kv, { driver: 'cloudflare-kv-binding', binding: 'KV' diff --git a/src/module.ts b/src/module.ts index 0569a168d..8e6c15000 100644 --- a/src/module.ts +++ b/src/module.ts @@ -1,5 +1,5 @@ -import { writeFile, readFile, mkdir } from 'node:fs/promises' -import { defineNuxtModule, logger, addTemplate } from '@nuxt/kit' +import { writeFile, readFile, mkdir, unlink } from 'node:fs/promises' +import { defineNuxtModule, logger, addTemplate, addServerPlugin } from '@nuxt/kit' import { join, relative, resolve as resolveFs } from 'pathe' import { defu } from 'defu' import { findWorkspaceDir, readPackageJSON } from 'pkg-types' @@ -11,11 +11,20 @@ import { setupKV } from './kv/setup' import { setupBlob } from './blob/setup' import type { ModuleOptions, HubConfig, ResolvedHubConfig } from '@nuxthub/core' import { addDevToolsCustomTabs } from './devtools' +import { resolve, hasRemoteBindingId } from './utils' import { setupCloudflare } from './hosting/cloudflare' import type { NuxtModule } from '@nuxt/schema' const log = logger.withTag('nuxt:hub') +// Escape special characters for TOML string values +const escapeToml = (str: string) => str + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t') + export * from './types/index' export default defineNuxtModule({ @@ -62,6 +71,61 @@ export default defineNuxtModule({ await setupDatabase(nuxt, hub as HubConfig, deps) await setupKV(nuxt, hub as HubConfig, deps) + // Setup remote Cloudflare bindings when binding IDs are present in dev + if (nuxt.options.dev && hasRemoteBindingId(hub)) { + nuxt.hook('modules:done', async () => { + const wranglerPath = join(hub.dir, 'wrangler.toml') + const wrangler = nuxt.options.nitro.cloudflare?.wrangler || {} + let tomlContent = '' + const remoteBindings = new Set() + + // D1 databases + for (const db of (wrangler.d1_databases || [])) { + tomlContent += `[[d1_databases]]\nbinding = "${escapeToml(db.binding)}"\ndatabase_name = "${escapeToml(db.database_name || 'default')}"\ndatabase_id = "${escapeToml(db.database_id || 'default')}"\nremote = true\n\n` + remoteBindings.add('D1') + } + // KV namespaces + for (const kv of (wrangler.kv_namespaces || [])) { + tomlContent += `[[kv_namespaces]]\nbinding = "${escapeToml(kv.binding)}"\nid = "${escapeToml(kv.id)}"\nremote = true\n\n` + remoteBindings.add('KV') + } + // R2 buckets + for (const r2 of (wrangler.r2_buckets || [])) { + tomlContent += `[[r2_buckets]]\nbinding = "${escapeToml(r2.binding)}"\nbucket_name = "${escapeToml(r2.bucket_name)}"\nremote = true\n\n` + remoteBindings.add('R2') + } + // Hyperdrive + for (const hd of (wrangler.hyperdrive || [])) { + tomlContent += `[[hyperdrive]]\nbinding = "${escapeToml(hd.binding)}"\nid = "${escapeToml(hd.id)}"\n\n` + remoteBindings.add('Hyperdrive') + } + + if (tomlContent) { + const bindings = [...remoteBindings].join(', ') + log.warn(`Remote binding IDs detected (${bindings}). Connecting to REMOTE Cloudflare resources.`) + log.warn('Seeds/migrations will run against PRODUCTION data. Use $production pattern to avoid this.') + + try { + await writeFile(wranglerPath, tomlContent, 'utf-8') + hub._remote = { configPath: wranglerPath, persistDir: hub.dir } + addServerPlugin(resolve('remote/runtime/plugin.dev')) + log.info(`Using remote Cloudflare bindings: ${[...remoteBindings].join(', ')}`) + } catch (error: unknown) { + log.error(`Failed to write wrangler config to ${wranglerPath}: ${error instanceof Error ? error.message : error}`) + } + } + }) + + // Cleanup wrangler.toml on close + nuxt.hook('close', async () => { + if (hub._remote?.configPath) { + await unlink(hub._remote.configPath).catch((e) => { + log.debug(`Failed to cleanup wrangler config: ${e instanceof Error ? e.message : e}`) + }) + } + }) + } + const runtimeConfig = nuxt.options.runtimeConfig runtimeConfig.hub = hub as ResolvedHubConfig runtimeConfig.public.hub ||= {} diff --git a/src/remote/runtime/plugin.dev.ts b/src/remote/runtime/plugin.dev.ts new file mode 100644 index 000000000..5291def93 --- /dev/null +++ b/src/remote/runtime/plugin.dev.ts @@ -0,0 +1,99 @@ +import type { NitroAppPlugin } from 'nitropack' +import type { GetPlatformProxyOptions } from 'wrangler' +// @ts-expect-error - virtual import +import { useRuntimeConfig } from '#imports' + +declare global { + + var __env__: Record | undefined +} + +let _initError: Error | null = null + +const _proxy = _getPlatformProxy() + .catch((error) => { + const isAuthError = error.message?.includes('login') || error.message?.includes('auth') || error.code === 'EAUTH' + const isNetworkError = error.message?.includes('ENOTFOUND') || error.message?.includes('network') || ['ENOTFOUND', 'ECONNREFUSED', 'ETIMEDOUT', 'ENETUNREACH'].includes(error.code) + + console.error('[nuxt:hub] Failed to initialize remote Cloudflare bindings') + console.error('[nuxt:hub] Error:', error.message || error) + + if (isAuthError) { + console.error('[nuxt:hub] This appears to be an authentication issue.') + console.error('[nuxt:hub] Ensure you are logged into wrangler: `npx wrangler login`') + } else if (isNetworkError) { + console.error('[nuxt:hub] This appears to be a network issue.') + console.error('[nuxt:hub] Check your internet connection and try again.') + } else { + console.error('[nuxt:hub] Verify your binding IDs are correct in nuxt.config') + } + + _initError = new Error(`[nuxt:hub] Cannot start with remote bindings: ${error.message || error}`) + throw _initError + }) + .then((proxy) => { + globalThis.__env__ = proxy.env as Record + return proxy + }) + +// Proxy that throws helpful errors when accessed before initialization or when init failed +globalThis.__env__ = new Proxy({} as Record, { + get(_, prop) { + if (_initError) throw _initError + throw new Error(`[nuxt:hub] Cloudflare bindings not ready. The "${String(prop)}" binding was accessed before initialization completed.`) + } +}) + +export default function (nitroApp) { + nitroApp.hooks.hook('request', async (event) => { + const proxy = await _proxy + + event.context.cf = proxy.cf + event.context.waitUntil = proxy.ctx.waitUntil.bind(proxy.ctx) + + event.context.cloudflare = { + ...event.context.cloudflare, + env: proxy.env, + context: proxy.ctx + } + + ;(event.node.req as any).__unenv__ = { + ...(event.node.req as any).__unenv__, + waitUntil: event.context.waitUntil + } + }) + + // Ensure our request hook runs first so bindings are available to all other handlers. + // This moves our hook (just added as last) to the front of the request hooks array. + // WARNING: This accesses internal Nitro hooks array - may break with Nitro updates + // @ts-expect-error - accessing internal hooks array + nitroApp.hooks._hooks.request?.unshift(nitroApp.hooks._hooks.request?.pop()) + + nitroApp.hooks.hook('close', () => { + return _proxy?.then(proxy => proxy.dispose()).catch((e) => { + console.debug('[nuxt:hub] Failed to dispose platform proxy:', e instanceof Error ? e.message : e) + }) + }) +} + +async function _getPlatformProxy() { + const _pkg = 'wrangler' + const { getPlatformProxy } = (await import(_pkg).catch(() => { + throw new Error('[nuxt:hub] Package `wrangler` not found. Please install it with: `npx nypm@latest add -D wrangler`') + })) as typeof import('wrangler') + + const runtimeConfig = useRuntimeConfig() as { + hub: { _remote?: { configPath: string, persistDir: string } } + } + + if (!runtimeConfig.hub._remote) { + throw new Error('[nuxt:hub] Remote configuration not found. This plugin should only be loaded when binding IDs are present.') + } + + const proxyOptions: GetPlatformProxyOptions = { + configPath: runtimeConfig.hub._remote.configPath, + persist: { path: runtimeConfig.hub._remote.persistDir } + } + + return await getPlatformProxy(proxyOptions) +} diff --git a/src/types/config.ts b/src/types/config.ts index c0edaa201..74f617aa3 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -11,6 +11,8 @@ export interface HubConfig { kv: boolean | KVConfig dir: string hosting: string + /** Internal: remote wrangler config (set when binding IDs are present in dev) */ + _remote?: { configPath: string, persistDir: string } } export interface ResolvedHubConfig extends HubConfig { diff --git a/src/utils.ts b/src/utils.ts index 9ac7aaa92..2caca08a8 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,8 +1,28 @@ import type { Nuxt } from '@nuxt/schema' import { logger, createResolver } from '@nuxt/kit' +import type { HubConfig, ResolvedHubConfig } from '@nuxthub/core' const log = logger.withTag('nuxt:hub') +/** + * Check if any Cloudflare binding ID is configured (databaseId, namespaceId, bucketName, hyperdriveId) + * When a binding ID is present, we use remote Cloudflare bindings via getPlatformProxy in dev + */ +export function hasRemoteBindingId(hub: HubConfig | ResolvedHubConfig): boolean { + const dbConfig = typeof hub.db === 'object' && hub.db ? hub.db : null + const kvConfig = typeof hub.kv === 'object' && hub.kv ? hub.kv : null + const cacheConfig = typeof hub.cache === 'object' && hub.cache ? hub.cache : null + const blobConfig = typeof hub.blob === 'object' && hub.blob ? hub.blob : null + + return !!( + dbConfig?.connection?.databaseId + || dbConfig?.connection?.hyperdriveId + || kvConfig?.namespaceId + || cacheConfig?.namespaceId + || (blobConfig?.driver === 'cloudflare-r2' && blobConfig.bucketName) + ) +} + export function logWhenReady(nuxt: Nuxt, message: string, type: 'info' | 'warn' | 'error' = 'info') { if (nuxt.options._prepare) { return diff --git a/test/auto-remote.test.ts b/test/auto-remote.test.ts new file mode 100644 index 000000000..423b4ce0c --- /dev/null +++ b/test/auto-remote.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest' +import { hasRemoteBindingId } from '../src/utils' +import type { HubConfig } from '../src/types' + +describe('hasRemoteBindingId', () => { + const createBaseConfig = (overrides: Partial = {}): HubConfig => ({ + blob: false, + cache: false, + db: false, + kv: false, + dir: '/tmp/test', + hosting: '', + ...overrides + }) + + it('returns false when no features are enabled', () => { + const hub = createBaseConfig() + expect(hasRemoteBindingId(hub)).toBe(false) + }) + + it('returns false when features are enabled but no binding IDs', () => { + const hub = createBaseConfig({ + db: { dialect: 'sqlite' }, + kv: true, + cache: true, + blob: true + }) + expect(hasRemoteBindingId(hub)).toBe(false) + }) + + describe('database', () => { + it('detects databaseId', () => { + const hub = createBaseConfig({ + db: { dialect: 'sqlite', connection: { databaseId: 'test-db-id' } } + }) + expect(hasRemoteBindingId(hub)).toBe(true) + }) + + it('detects hyperdriveId', () => { + const hub = createBaseConfig({ + db: { dialect: 'postgresql', connection: { hyperdriveId: 'test-hyperdrive-id' } } + }) + expect(hasRemoteBindingId(hub)).toBe(true) + }) + }) + + describe('kv', () => { + it('detects namespaceId', () => { + const hub = createBaseConfig({ + kv: { namespaceId: 'test-kv-id' } + }) + expect(hasRemoteBindingId(hub)).toBe(true) + }) + + it('ignores kv: true without namespaceId', () => { + const hub = createBaseConfig({ kv: true }) + expect(hasRemoteBindingId(hub)).toBe(false) + }) + }) + + describe('cache', () => { + it('detects namespaceId', () => { + const hub = createBaseConfig({ + cache: { namespaceId: 'test-cache-id' } + }) + expect(hasRemoteBindingId(hub)).toBe(true) + }) + + it('ignores cache: true without namespaceId', () => { + const hub = createBaseConfig({ cache: true }) + expect(hasRemoteBindingId(hub)).toBe(false) + }) + }) + + describe('blob', () => { + it('detects bucketName', () => { + const hub = createBaseConfig({ + blob: { driver: 'cloudflare-r2', bucketName: 'test-bucket' } + }) + expect(hasRemoteBindingId(hub)).toBe(true) + }) + + it('ignores blob: true without bucketName', () => { + const hub = createBaseConfig({ blob: true }) + expect(hasRemoteBindingId(hub)).toBe(false) + }) + + it('ignores blob with other drivers', () => { + const hub = createBaseConfig({ + blob: { driver: 'fs', dir: '/tmp/blob' } + }) + expect(hasRemoteBindingId(hub)).toBe(false) + }) + + it('ignores bucketName without cloudflare-r2 driver', () => { + const hub = createBaseConfig({ + blob: { bucketName: 'test-bucket' } as any + }) + expect(hasRemoteBindingId(hub)).toBe(false) + }) + }) + + describe('multiple bindings', () => { + it('detects when any binding ID is present', () => { + const hub = createBaseConfig({ + db: { dialect: 'sqlite' }, // no databaseId + kv: { namespaceId: 'test-kv-id' } // has namespaceId + }) + expect(hasRemoteBindingId(hub)).toBe(true) + }) + }) +}) diff --git a/test/database.config.test.ts b/test/database.config.test.ts index 283c740e5..f865a0b66 100644 --- a/test/database.config.test.ts +++ b/test/database.config.test.ts @@ -119,6 +119,23 @@ describe('resolveDatabaseConfig', () => { }) }) + it('should auto-detect D1 driver when databaseId is present (non-cloudflare)', async () => { + const nuxt = createMockNuxt() + const hub = createBaseHubConfig({ + dialect: 'sqlite', + connection: { databaseId: 'test-db-id' } + }) + // Note: hosting is empty string (not cloudflare) + + const result = await resolveDatabaseConfig(nuxt, hub) + + expect(result).toMatchObject({ + dialect: 'sqlite', + driver: 'd1', + applyMigrationsDuringBuild: false + }) + }) + it('should preserve custom driver when specified', async () => { const nuxt = createMockNuxt() const hub = createBaseHubConfig({ @@ -240,6 +257,22 @@ describe('resolveDatabaseConfig', () => { }) }) + it('should auto-detect postgres-js with hyperdriveId (non-cloudflare)', async () => { + const nuxt = createMockNuxt() + const hub = createBaseHubConfig({ + dialect: 'postgresql', + connection: { hyperdriveId: 'test-hyperdrive-id' } + }) + // Note: hosting is empty string (not cloudflare) + + const result = await resolveDatabaseConfig(nuxt, hub) + + expect(result).toMatchObject({ + dialect: 'postgresql', + driver: 'postgres-js' + }) + }) + it('should preserve custom driver when specified', async () => { process.env.DATABASE_URL = 'postgresql://user:pass@localhost:5432/db' @@ -405,6 +438,22 @@ describe('resolveDatabaseConfig', () => { }) }) + it('should auto-detect mysql2 with hyperdriveId (non-cloudflare)', async () => { + const nuxt = createMockNuxt() + const hub = createBaseHubConfig({ + dialect: 'mysql', + connection: { hyperdriveId: 'test-hyperdrive-id' } + }) + // Note: hosting is empty string (not cloudflare) + + const result = await resolveDatabaseConfig(nuxt, hub) + + expect(result).toMatchObject({ + dialect: 'mysql', + driver: 'mysql2' + }) + }) + it('should preserve custom driver when specified', async () => { process.env.MYSQL_URL = 'mysql://user:pass@localhost:3306/db' diff --git a/test/storage.config.test.ts b/test/storage.config.test.ts new file mode 100644 index 000000000..d72a4c23b --- /dev/null +++ b/test/storage.config.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from 'vitest' +import { resolveKVConfig } from '../src/kv/setup' +import { resolveCacheConfig } from '../src/cache/setup' +import { resolveBlobConfig } from '../src/blob/setup' +import type { HubConfig } from '../src/types' + +describe('resolveKVConfig', () => { + const createBaseConfig = (kv: HubConfig['kv']): HubConfig => ({ + blob: false, + cache: false, + db: false, + kv, + dir: '/tmp/test', + hosting: '' + }) + + it('returns false when kv is false', () => { + const hub = createBaseConfig(false) + expect(resolveKVConfig(hub)).toBe(false) + }) + + it('uses fs-lite by default (non-cloudflare)', () => { + const hub = createBaseConfig(true) + expect(resolveKVConfig(hub)).toMatchObject({ + driver: 'fs-lite', + base: '.data/kv' + }) + }) + + it('uses cloudflare-kv-binding when hosting is cloudflare', () => { + const hub = createBaseConfig(true) + hub.hosting = 'cloudflare' + expect(resolveKVConfig(hub)).toMatchObject({ + driver: 'cloudflare-kv-binding', + binding: 'KV' + }) + }) + + it('auto-detects cloudflare-kv-binding when namespaceId present (non-cloudflare)', () => { + const hub = createBaseConfig({ namespaceId: 'test-kv-id' }) + // Note: hosting is empty string + expect(resolveKVConfig(hub)).toMatchObject({ + driver: 'cloudflare-kv-binding', + binding: 'KV', + namespaceId: 'test-kv-id' + }) + }) + + it('preserves user driver when specified', () => { + const hub = createBaseConfig({ driver: 'redis', url: 'redis://localhost' }) + expect(resolveKVConfig(hub)).toMatchObject({ + driver: 'redis', + url: 'redis://localhost' + }) + }) +}) + +describe('resolveCacheConfig', () => { + const createBaseConfig = (cache: HubConfig['cache']): HubConfig => ({ + blob: false, + cache, + db: false, + kv: false, + dir: '/tmp/test', + hosting: '' + }) + + it('returns false when cache is false', () => { + const hub = createBaseConfig(false) + expect(resolveCacheConfig(hub)).toBe(false) + }) + + it('uses fs-lite by default (non-cloudflare)', () => { + const hub = createBaseConfig(true) + expect(resolveCacheConfig(hub)).toMatchObject({ + driver: 'fs-lite', + base: '/tmp/test/cache' + }) + }) + + it('uses cloudflare-kv-binding when hosting is cloudflare', () => { + const hub = createBaseConfig(true) + hub.hosting = 'cloudflare' + expect(resolveCacheConfig(hub)).toMatchObject({ + driver: 'cloudflare-kv-binding', + binding: 'CACHE' + }) + }) + + it('auto-detects cloudflare-kv-binding when namespaceId present (non-cloudflare)', () => { + const hub = createBaseConfig({ namespaceId: 'test-cache-id' }) + // Note: hosting is empty string + expect(resolveCacheConfig(hub)).toMatchObject({ + driver: 'cloudflare-kv-binding', + binding: 'CACHE', + namespaceId: 'test-cache-id' + }) + }) + + it('preserves user driver when specified', () => { + const hub = createBaseConfig({ driver: 'memory' }) + expect(resolveCacheConfig(hub)).toMatchObject({ driver: 'memory' }) + }) +}) + +describe('resolveBlobConfig', () => { + const createBaseConfig = (blob: HubConfig['blob']): HubConfig => ({ + blob, + cache: false, + db: false, + kv: false, + dir: '/tmp/test', + hosting: '' + }) + + it('returns false when blob is false', () => { + const hub = createBaseConfig(false) + expect(resolveBlobConfig(hub, {})).toBe(false) + }) + + it('uses fs by default (non-cloudflare)', () => { + const hub = createBaseConfig(true) + expect(resolveBlobConfig(hub, {})).toMatchObject({ + driver: 'fs', + dir: '/tmp/test/blob' + }) + }) + + it('uses cloudflare-r2 when hosting is cloudflare', () => { + const hub = createBaseConfig(true) + hub.hosting = 'cloudflare' + expect(resolveBlobConfig(hub, {})).toMatchObject({ + driver: 'cloudflare-r2', + binding: 'BLOB' + }) + }) + + it('auto-detects cloudflare-r2 when bucketName present (non-cloudflare)', () => { + const hub = createBaseConfig({ driver: 'cloudflare-r2', bucketName: 'test-bucket' }) + // Note: hosting is empty string + expect(resolveBlobConfig(hub, {})).toMatchObject({ + driver: 'cloudflare-r2', + bucketName: 'test-bucket' + }) + }) + + it('preserves user driver when specified', () => { + const hub = createBaseConfig({ driver: 's3', bucket: 'my-bucket', accessKeyId: 'key', secretAccessKey: 'secret', region: 'us-east-1' }) + expect(resolveBlobConfig(hub, { aws4fetch: '1.0.0' })).toMatchObject({ + driver: 's3', + bucket: 'my-bucket' + }) + }) +})