diff --git a/examples/vercel/.gitignore b/examples/vercel/.gitignore new file mode 100644 index 000000000..4ad5857fb --- /dev/null +++ b/examples/vercel/.gitignore @@ -0,0 +1,18 @@ +# Dependencies +node_modules + +# Build outputs +dist +api/_handler.js +api/_handler.js.map + +# Environment variables +.env +.env.local +.env.production + +# Vercel +.vercel + +# OS +.DS_Store diff --git a/examples/vercel/.npmrc b/examples/vercel/.npmrc new file mode 100644 index 000000000..a5b48589f --- /dev/null +++ b/examples/vercel/.npmrc @@ -0,0 +1,3 @@ +# pnpm configuration for Vercel deployment +# Use hoisted node_modules to avoid symlink issues in serverless functions +node-linker=hoisted diff --git a/examples/vercel/README.md b/examples/vercel/README.md new file mode 100644 index 000000000..3cd77b9b1 --- /dev/null +++ b/examples/vercel/README.md @@ -0,0 +1,164 @@ +# Vercel Deployment Example + +Deploy an ObjectStack server with Hono to Vercel. + +## Features + +- ✅ Hono adapter for fast, edge-compatible API routes +- ✅ Turso/LibSQL database driver with in-memory fallback +- ✅ Authentication with better-auth +- ✅ Security plugin for RBAC +- ✅ Optimized serverless function bundling with esbuild +- ✅ Environment-based configuration + +## Prerequisites + +1. A [Vercel](https://vercel.com) account +2. A [Turso](https://turso.tech) database (optional, uses in-memory storage if not configured) + +## Local Development + +```bash +# Install dependencies (from monorepo root) +pnpm install + +# Start local development server +cd examples/vercel +pnpm dev +``` + +The server will be available at `http://localhost:3000/api/v1`. + +## Deployment to Vercel + +### Option 1: Deploy via Vercel CLI + +```bash +# Install Vercel CLI +npm i -g vercel + +# Deploy from the examples/vercel directory +cd examples/vercel +vercel +``` + +### Option 2: Deploy via Vercel Dashboard + +1. Import your GitHub repository in the [Vercel Dashboard](https://vercel.com/new) +2. Set the **Root Directory** to `examples/vercel` +3. Configure environment variables (see below) +4. Click **Deploy** + +## Environment Variables + +Configure these in your Vercel project settings: + +| Variable | Description | Required | Example | +|----------|-------------|----------|---------| +| `TURSO_DATABASE_URL` | Turso database connection URL | No* | `libsql://your-db.turso.io` | +| `TURSO_AUTH_TOKEN` | Turso authentication token | No* | `eyJ...` | +| `AUTH_SECRET` | Secret key for authentication (min 32 chars) | Yes | Generate with `openssl rand -base64 32` | + +*If not set, the server will use an in-memory database (data will be lost on restart). + +### Setting up Turso Database + +```bash +# Install Turso CLI +curl -sSfL https://get.tur.so/install.sh | bash + +# Create a new database +turso db create objectstack-vercel + +# Get the database URL +turso db show objectstack-vercel --url + +# Create an auth token +turso db tokens create objectstack-vercel + +# Add both values to Vercel environment variables +``` + +## Project Structure + +``` +examples/vercel/ +├── api/ +│ └── [[...route]].js # Vercel serverless function entry point +├── scripts/ +│ ├── bundle-api.mjs # esbuild bundler for serverless function +│ └── build-vercel.sh # Vercel build script +├── server/ +│ └── index.ts # Server entrypoint with kernel bootstrap +├── objectstack.config.ts # ObjectStack configuration +├── package.json +├── tsconfig.json +├── vercel.json # Vercel deployment configuration +└── README.md +``` + +## How It Works + +1. **Build Step**: `scripts/build-vercel.sh` runs on Vercel, which: + - Builds the monorepo using turbo + - Bundles `server/index.ts` → `api/_handler.js` using esbuild + +2. **Runtime**: Vercel routes requests to `api/[[...route]].js`, which: + - Lazily boots the ObjectStack kernel on first request + - Delegates to the Hono adapter for request handling + - Persists kernel state across warm invocations + +3. **Database**: + - Production: Uses Turso (edge-compatible LibSQL) + - Local dev: Falls back to in-memory driver + +## API Routes + +All ObjectStack API routes are available under `/api/v1`: + +- `GET /api/v1/meta` - Metadata discovery +- `GET /api/v1/data/:object` - Query data +- `POST /api/v1/data/:object` - Insert records +- `PATCH /api/v1/data/:object/:id` - Update records +- `DELETE /api/v1/data/:object/:id` - Delete records +- `POST /api/v1/auth/sign-in` - Authentication +- And more... + +## Testing the Deployment + +```bash +# Health check +curl https://your-deployment.vercel.app/api/v1/meta + +# Example API request (after authentication) +curl https://your-deployment.vercel.app/api/v1/data/users \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +## Troubleshooting + +### Build fails with "Module not found" + +Make sure you're running the build from the monorepo root, or that Vercel's `installCommand` is set correctly in `vercel.json`. + +### Database connection issues + +- Verify `TURSO_DATABASE_URL` and `TURSO_AUTH_TOKEN` are set correctly +- Check Turso database is accessible from Vercel's network +- For debugging, you can temporarily use `:memory:` as the database URL + +### Cold start timeout + +- Increase `maxDuration` in `vercel.json` if needed +- Consider using Vercel Pro for higher limits + +## Learn More + +- [ObjectStack Documentation](https://docs.objectstack.dev) +- [Hono Vercel Deployment Guide](https://vercel.com/docs/frameworks/backend/hono) +- [Turso Documentation](https://docs.turso.tech) +- [Vercel Serverless Functions](https://vercel.com/docs/functions/serverless-functions) + +## License + +Apache-2.0 diff --git a/examples/vercel/api/[[...route]].js b/examples/vercel/api/[[...route]].js new file mode 100644 index 000000000..648a4bbce --- /dev/null +++ b/examples/vercel/api/[[...route]].js @@ -0,0 +1,9 @@ +// Vercel Serverless Function — Catch-all API route. +// +// This file MUST be committed to the repository so Vercel can detect it +// as a serverless function during the pre-build phase. +// +// It delegates to the esbuild bundle (`_handler.js`) generated by +// `scripts/bundle-api.mjs` during the Vercel build step. + +export { default, config } from './_handler.js'; diff --git a/examples/vercel/objectstack.config.ts b/examples/vercel/objectstack.config.ts new file mode 100644 index 000000000..8410ec576 --- /dev/null +++ b/examples/vercel/objectstack.config.ts @@ -0,0 +1,88 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineStack } from '@objectstack/spec'; +import { AppPlugin, DriverPlugin } from '@objectstack/runtime'; +import { ObjectQLPlugin } from '@objectstack/objectql'; +import { TursoDriver } from '@objectstack/driver-turso'; +import { InMemoryDriver } from '@objectstack/driver-memory'; +import { AuthPlugin } from '@objectstack/plugin-auth'; +import { SecurityPlugin } from '@objectstack/plugin-security'; +import { MetadataPlugin } from '@objectstack/metadata'; + +/** + * Vercel Deployment Example + * + * This example demonstrates how to deploy an ObjectStack server to Vercel + * using the Hono adapter. It includes: + * + * - TursoDriver for production (with fallback to in-memory for local dev) + * - Authentication with better-auth (environment-based configuration) + * - Security plugin for RBAC + * - Metadata plugin for runtime metadata management + * + * Environment Variables (set in Vercel dashboard or .env.local): + * - TURSO_DATABASE_URL: Turso database connection URL (or ":memory:" for local) + * - TURSO_AUTH_TOKEN: Turso authentication token (optional for local) + * - AUTH_SECRET: Secret key for authentication (min 32 characters) + * - VERCEL_URL: Auto-injected by Vercel (deployment URL) + * - VERCEL_PROJECT_PRODUCTION_URL: Auto-injected by Vercel (production URL) + */ + +// Determine if we're running in production (Vercel) or local dev +const isProduction = process.env.VERCEL === '1'; + +// Database driver: Use Turso in production, in-memory for local dev +const driver = isProduction || process.env.TURSO_DATABASE_URL + ? new TursoDriver({ + url: process.env.TURSO_DATABASE_URL ?? ':memory:', + ...(process.env.TURSO_AUTH_TOKEN && { authToken: process.env.TURSO_AUTH_TOKEN }), + }) + : new InMemoryDriver(); + +// Base URL for authentication (auto-detected from Vercel environment) +const baseUrl = process.env.VERCEL_PROJECT_PRODUCTION_URL + ? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}` + : process.env.VERCEL_URL + ? `https://${process.env.VERCEL_URL}` + : 'http://localhost:3000'; + +// Collect trusted origins for CORS and CSRF protection +function getVercelOrigins(): string[] { + const origins: string[] = []; + if (process.env.VERCEL_URL) { + origins.push(`https://${process.env.VERCEL_URL}`); + } + if (process.env.VERCEL_BRANCH_URL) { + origins.push(`https://${process.env.VERCEL_BRANCH_URL}`); + } + if (process.env.VERCEL_PROJECT_PRODUCTION_URL) { + origins.push(`https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`); + } + return origins; +} + +const trustedOrigins = getVercelOrigins(); + +export default defineStack({ + manifest: { + id: 'com.example.vercel', + namespace: 'vercel', + name: 'Vercel Deployment Example', + version: '1.0.0', + description: 'Example application demonstrating Hono deployment to Vercel', + type: 'app', + }, + + // Core plugins required for a functional ObjectStack server + plugins: [ + new ObjectQLPlugin(), + new DriverPlugin(driver), + new AuthPlugin({ + secret: process.env.AUTH_SECRET ?? 'dev-secret-please-change-in-production-min-32-chars', + baseUrl, + ...(trustedOrigins.length > 0 ? { trustedOrigins } : {}), + }), + new SecurityPlugin(), + new MetadataPlugin({ watch: false }), // Disable file watching on Vercel + ], +}); diff --git a/examples/vercel/package.json b/examples/vercel/package.json new file mode 100644 index 000000000..7dbdd64b6 --- /dev/null +++ b/examples/vercel/package.json @@ -0,0 +1,32 @@ +{ + "name": "@example/vercel", + "version": "4.0.3", + "description": "Example: Deploy ObjectStack server with Hono to Vercel", + "license": "Apache-2.0", + "private": true, + "type": "module", + "scripts": { + "dev": "hono dev", + "build": "bash scripts/build-vercel.sh", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@hono/node-server": "^1.19.14", + "@objectstack/core": "workspace:*", + "@objectstack/driver-memory": "workspace:*", + "@objectstack/driver-turso": "workspace:*", + "@objectstack/hono": "workspace:*", + "@objectstack/metadata": "workspace:*", + "@objectstack/objectql": "workspace:*", + "@objectstack/plugin-auth": "workspace:*", + "@objectstack/plugin-security": "workspace:*", + "@objectstack/runtime": "workspace:*", + "@objectstack/spec": "workspace:*", + "hono": "^4.12.12" + }, + "devDependencies": { + "@types/node": "^22.14.3", + "esbuild": "^0.28.0", + "typescript": "^6.0.2" + } +} diff --git a/examples/vercel/scripts/build-vercel.sh b/examples/vercel/scripts/build-vercel.sh new file mode 100755 index 000000000..23523d909 --- /dev/null +++ b/examples/vercel/scripts/build-vercel.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Build script for Vercel deployment of ObjectStack server with Hono. +# +# This script: +# 1. Builds the monorepo from the root using turbo +# 2. Bundles the serverless function using esbuild +# +# The bundled function is self-contained and ready for Vercel deployment. + +echo "[build-vercel] Starting build..." + +# 1. Build the monorepo from the root +cd ../.. +pnpm turbo run build --filter=@example/vercel +cd examples/vercel + +# 2. Bundle API serverless function +node scripts/bundle-api.mjs + +echo "[build-vercel] Done. Serverless function ready at api/_handler.js" diff --git a/examples/vercel/scripts/bundle-api.mjs b/examples/vercel/scripts/bundle-api.mjs new file mode 100644 index 000000000..abc29b4bf --- /dev/null +++ b/examples/vercel/scripts/bundle-api.mjs @@ -0,0 +1,49 @@ +/** + * Pre-bundles the Vercel serverless API function. + * + * This script bundles server/index.ts with dependencies inlined, + * creating a self-contained serverless function for Vercel deployment. + * + * Native packages like better-sqlite3 are kept external and will be + * packaged separately by Vercel. + */ + +import { build } from 'esbuild'; + +// Packages that cannot be bundled (native bindings / optional drivers) +const EXTERNAL = [ + 'better-sqlite3', + '@libsql/client', + // Optional knex database drivers + 'pg', + 'pg-native', + 'pg-query-stream', + 'mysql', + 'mysql2', + 'sqlite3', + 'oracledb', + 'tedious', + // macOS-only native file watcher + 'fsevents', +]; + +await build({ + entryPoints: ['server/index.ts'], + bundle: true, + platform: 'node', + format: 'esm', + target: 'es2020', + outfile: 'api/_handler.js', + sourcemap: true, + external: EXTERNAL, + logOverride: { 'require-resolve-not-external': 'silent' }, + banner: { + js: [ + '// Bundled by esbuild — see scripts/bundle-api.mjs', + 'import { createRequire } from "module";', + 'const require = createRequire(import.meta.url);', + ].join('\n'), + }, +}); + +console.log('[bundle-api] Bundled server/index.ts → api/_handler.js'); diff --git a/examples/vercel/server/index.ts b/examples/vercel/server/index.ts new file mode 100644 index 000000000..297bcc2ab --- /dev/null +++ b/examples/vercel/server/index.ts @@ -0,0 +1,198 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Vercel Serverless API Entrypoint + * + * This module boots the ObjectStack kernel on the first request and + * delegates all /api/* traffic to the ObjectStack Hono adapter. + * + * The kernel is initialized lazily (singleton pattern) and persists + * across warm invocations for better performance. + * + * Uses `getRequestListener()` from `@hono/node-server` to handle + * Vercel's pre-buffered request body (see extractBody helper). + */ + +import { ObjectKernel } from '@objectstack/runtime'; +import { createHonoApp } from '@objectstack/hono'; +import { getRequestListener } from '@hono/node-server'; +import type { Hono } from 'hono'; +import config from '../objectstack.config.js'; + +// --------------------------------------------------------------------------- +// Singleton state — persists across warm Vercel invocations +// --------------------------------------------------------------------------- + +let _kernel: ObjectKernel | null = null; +let _app: Hono | null = null; + +/** Shared boot promise — prevents concurrent cold-start races. */ +let _bootPromise: Promise | null = null; + +// --------------------------------------------------------------------------- +// Kernel bootstrap +// --------------------------------------------------------------------------- + +/** + * Boot the ObjectStack kernel (one-time cold-start cost). + * + * Uses a shared promise so that concurrent requests during a cold start + * wait for the same boot sequence rather than starting duplicates. + */ +async function ensureKernel(): Promise { + if (_kernel) return _kernel; + if (_bootPromise) return _bootPromise; + + _bootPromise = (async () => { + console.log('[Vercel] Booting ObjectStack Kernel...'); + + try { + const kernel = new ObjectKernel(); + + // Load plugins from config + if (config.plugins) { + for (const plugin of config.plugins) { + await kernel.use(plugin); + } + } + + await kernel.bootstrap(); + + _kernel = kernel; + console.log('[Vercel] Kernel ready.'); + return kernel; + } catch (err) { + // Clear the lock so the next request can retry + _bootPromise = null; + console.error('[Vercel] Kernel boot failed:', (err as any)?.message || err); + throw err; + } + })(); + + return _bootPromise; +} + +// --------------------------------------------------------------------------- +// Hono app factory +// --------------------------------------------------------------------------- + +/** + * Get (or create) the Hono application backed by the ObjectStack kernel. + */ +async function ensureApp(): Promise { + if (_app) return _app; + + const kernel = await ensureKernel(); + _app = createHonoApp({ kernel, prefix: '/api/v1' }); + return _app; +} + +// --------------------------------------------------------------------------- +// Body extraction — reads Vercel's pre-buffered request body +// --------------------------------------------------------------------------- + +/** Shape of the Vercel-augmented IncomingMessage passed via `env.incoming`. */ +interface VercelIncomingMessage { + rawBody?: Buffer | string; + body?: unknown; + headers?: Record; +} + +/** Shape of the env object provided by `getRequestListener` on Vercel. */ +interface VercelEnv { + incoming?: VercelIncomingMessage; +} + +function extractBody( + incoming: VercelIncomingMessage, + method: string, + contentType: string | undefined, +): BodyInit | null { + if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') return null; + + if (incoming.rawBody != null) { + return incoming.rawBody; + } + + if (incoming.body != null) { + if (typeof incoming.body === 'string') return incoming.body; + if (contentType?.includes('application/json')) return JSON.stringify(incoming.body); + return String(incoming.body); + } + + return null; +} + +/** + * Derive the correct public URL for the request, fixing the protocol when + * running behind a reverse proxy such as Vercel's edge network. + */ +function resolvePublicUrl( + requestUrl: string, + incoming: VercelIncomingMessage | undefined, +): string { + if (!incoming) return requestUrl; + const fwdProto = incoming.headers?.['x-forwarded-proto']; + const rawProto = Array.isArray(fwdProto) ? fwdProto[0] : fwdProto; + const proto = rawProto === 'https' || rawProto === 'http' ? rawProto : undefined; + if (proto === 'https' && requestUrl.startsWith('http:')) { + return requestUrl.replace(/^http:/, 'https:'); + } + return requestUrl; +} + +// --------------------------------------------------------------------------- +// Vercel serverless handler +// --------------------------------------------------------------------------- + +export default getRequestListener(async (request, env) => { + let app: Hono; + try { + app = await ensureApp(); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + console.error('[Vercel] Handler error — bootstrap did not complete:', message); + return new Response( + JSON.stringify({ + success: false, + error: { + message: 'Service Unavailable — kernel bootstrap failed.', + code: 503, + }, + }), + { status: 503, headers: { 'content-type': 'application/json' } }, + ); + } + + const method = request.method.toUpperCase(); + const incoming = (env as VercelEnv)?.incoming; + + // Fix URL protocol using x-forwarded-proto + const url = resolvePublicUrl(request.url, incoming); + + console.log(`[Vercel] ${method} ${url}`); + + if (method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS' && incoming) { + const contentType = incoming.headers?.['content-type']; + const contentTypeStr = Array.isArray(contentType) ? contentType[0] : contentType; + const body = extractBody(incoming, method, contentTypeStr); + if (body != null) { + return await app.fetch( + new Request(url, { method, headers: request.headers, body }), + ); + } + } + + // For GET/HEAD/OPTIONS (or body-less requests) + return await app.fetch( + new Request(url, { method, headers: request.headers }), + ); +}); + +/** + * Vercel per-function configuration. + */ +export const config = { + memory: 1024, + maxDuration: 60, +}; diff --git a/examples/vercel/tsconfig.json b/examples/vercel/tsconfig.json new file mode 100644 index 000000000..52c0b1d0f --- /dev/null +++ b/examples/vercel/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "lib": ["ES2020"], + "moduleResolution": "bundler", + "esModuleInterop": true, + "skipLibCheck": true, + "strict": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["**/*.ts", "**/*.mjs"], + "exclude": ["node_modules", "dist", "api/_handler.js"] +} diff --git a/examples/vercel/vercel.json b/examples/vercel/vercel.json new file mode 100644 index 000000000..7c2d6eca0 --- /dev/null +++ b/examples/vercel/vercel.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": null, + "installCommand": "cd ../.. && pnpm install", + "buildCommand": "bash scripts/build-vercel.sh", + "functions": { + "api/**/*.js": { + "memory": 1024, + "maxDuration": 60 + } + }, + "rewrites": [ + { "source": "/api/:path*", "destination": "/api/[[...route]]" } + ] +}