diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e1d725cd..bf64f6913 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed +- **Studio Vercel API routes returning HTML instead of JSON** — Adopted the + same Vercel deployment pattern used by `hotcrm`: committed + `api/[[...route]].js` catch-all route so Vercel detects it pre-build, + switched esbuild output from CJS to ESM (fixes `"type": "module"` conflict), + and changed the bundle output to `api/_handler.js` (a separate file that + the committed wrapper re-exports). This avoids both Vercel's TS + compilation overwriting the bundle (`ERR_MODULE_NOT_FOUND`) and the + "File not found" error from deleting source files during build. + Added `createRequire` banner to the esbuild config so that CJS + dependencies (knex/tarn) can `require()` Node.js built-in modules like + `events` without the "Dynamic require is not supported" error. + Added `functions.includeFiles` in `vercel.json` to include native addons + (`better-sqlite3`, `@libsql/client`) that esbuild cannot bundle. + Added a build step to copy native external modules from the monorepo root + `node_modules/` into the studio's local `node_modules/`, since pnpm's strict + mode (unlike hotcrm's `shamefully-hoist`) doesn't symlink transitive native + dependencies into app-level `node_modules/`. + Updated rewrites to match: `/api/:path*` → `/api/[[...route]]`. +- **Studio CORS error on Vercel temporary/preview domains** — Changed + `VITE_SERVER_URL` from hardcoded `https://play.objectstack.ai` to `""` + (empty string / same-origin) in `vercel.json` so each deployment — including + previews — calls its own serverless function instead of the production API + cross-origin. Also added Hono CORS middleware to `apps/studio/server/index.ts` + as a safety net for any remaining cross-origin scenarios; dynamically allows + all `*.vercel.app` subdomains, explicitly listed Vercel deployment URLs, and + localhost. Extracted `getVercelOrigins()` helper to keep CORS and + better-auth `trustedOrigins` allowlists in sync. - **Client test aligned with removed `ai.chat` method** — Updated `@objectstack/client` test suite to match the current API surface where `ai.chat()` was removed in favour of the Vercel AI SDK `useChat()` hook. diff --git a/apps/studio/.gitignore b/apps/studio/.gitignore index a6c2cc954..4c09dba96 100644 --- a/apps/studio/.gitignore +++ b/apps/studio/.gitignore @@ -5,7 +5,8 @@ pnpm-lock.yaml # Build outputs dist build -api +api/_handler.js +api/_handler.js.map *.tsbuildinfo # Development diff --git a/apps/studio/api/[[...route]].js b/apps/studio/api/[[...route]].js new file mode 100644 index 000000000..864c37217 --- /dev/null +++ b/apps/studio/api/[[...route]].js @@ -0,0 +1,17 @@ +// 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. A separate +// bundle file is used (rather than overwriting this file) so that: +// 1. Vercel always finds this committed entry point (no "File not found"). +// 2. Vercel does not TypeScript-compile a .ts stub that references +// source files absent at runtime (no ERR_MODULE_NOT_FOUND). +// +// @see ../server/index.ts — the actual server entrypoint +// @see ../scripts/bundle-api.mjs — the esbuild bundler +// @see https://github.com/objectstack-ai/hotcrm/blob/main/vercel.json + +export { default, config } from './_handler.js'; diff --git a/apps/studio/scripts/build-vercel.sh b/apps/studio/scripts/build-vercel.sh index 2c20e69b3..bd8908d79 100755 --- a/apps/studio/scripts/build-vercel.sh +++ b/apps/studio/scripts/build-vercel.sh @@ -3,12 +3,19 @@ set -euo pipefail # Build script for Vercel deployment of @objectstack/studio. # -# Without outputDirectory, Vercel serves static files from public/. -# Serverless functions are detected from api/ at the project root. +# Follows the same Vercel deployment pattern as hotcrm: +# - api/[[...route]].js is committed to the repo (Vercel detects it pre-build) +# - esbuild bundles server/index.ts → api/_handler.js (self-contained bundle) +# - The committed .js wrapper re-exports from _handler.js at runtime +# - Vite SPA output is copied to public/ for CDN serving +# +# Vercel routing (framework: null, no outputDirectory): +# - Static files: served from public/ +# - Serverless functions: detected from api/ at project root # # Steps: # 1. Turbo build (Vite SPA → dist/) -# 2. Bundle the API serverless function (→ api/index.js) +# 2. Bundle the API serverless function (→ api/_handler.js) # 3. Copy Vite output to public/ for Vercel CDN serving echo "[build-vercel] Starting studio build..." @@ -21,9 +28,43 @@ cd apps/studio # 2. Bundle API serverless function node scripts/bundle-api.mjs -# 3. Copy Vite build output to public/ for static file serving +# 3. Copy native/external modules into local node_modules for Vercel packaging. +# +# Unlike hotcrm (which uses shamefully-hoist=true), this monorepo uses pnpm's +# default strict node_modules structure. Transitive native dependencies like +# better-sqlite3 only exist in the monorepo root's node_modules/.pnpm/ virtual +# store — they're NOT symlinked into apps/studio/node_modules/. +# +# The vercel.json includeFiles pattern references node_modules/ relative to +# apps/studio/, so we must copy the actual module files here for Vercel to +# include them in the serverless function's deployment package. +echo "[build-vercel] Copying external native modules to local node_modules..." +for mod in better-sqlite3; do + src="../../node_modules/$mod" + if [ -e "$src" ]; then + dest="node_modules/$mod" + mkdir -p "$(dirname "$dest")" + cp -rL "$src" "$dest" + echo "[build-vercel] ✓ Copied $mod" + else + echo "[build-vercel] ⚠ $mod not found at $src (skipped)" + fi +done +# Copy the @libsql scope (client + its sub-dependencies like core, hrana-client) +if [ -d "../../node_modules/@libsql" ]; then + mkdir -p "node_modules/@libsql" + for pkg in ../../node_modules/@libsql/*/; do + pkgname="$(basename "$pkg")" + cp -rL "$pkg" "node_modules/@libsql/$pkgname" + done + echo "[build-vercel] ✓ Copied @libsql/*" +else + echo "[build-vercel] ⚠ @libsql not found (skipped)" +fi + +# 4. Copy Vite build output to public/ for static file serving rm -rf public mkdir -p public cp -r dist/* public/ -echo "[build-vercel] Done. Static files in public/, serverless function in api/index.js" +echo "[build-vercel] Done. Static files in public/, serverless function in api/[[...route]].js → api/_handler.js" diff --git a/apps/studio/scripts/bundle-api.mjs b/apps/studio/scripts/bundle-api.mjs index 96781fa15..ec985e9d0 100644 --- a/apps/studio/scripts/bundle-api.mjs +++ b/apps/studio/scripts/bundle-api.mjs @@ -35,13 +35,29 @@ await build({ entryPoints: ['server/index.ts'], bundle: true, platform: 'node', - format: 'cjs', + format: 'esm', target: 'es2020', - outfile: 'api/index.js', + outfile: 'api/_handler.js', sourcemap: true, external: EXTERNAL, // Silence warnings about optional/unused require() calls in knex drivers logOverride: { 'require-resolve-not-external': 'silent' }, + // Vercel resolves ESM .js files correctly when "type": "module" is set. + // CJS format would conflict with the project's "type": "module" setting, + // causing Node.js to fail parsing require()/module.exports as ESM syntax. + // + // The createRequire banner provides a real `require` function in the ESM + // scope. esbuild's __require shim (generated for CJS→ESM conversion) + // checks `typeof require !== "undefined"` and uses it when available, + // which fixes "Dynamic require of is not supported" errors + // from CJS dependencies like knex/tarn that require() Node.js built-ins. + 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/index.js'); +console.log('[bundle-api] Bundled server/index.ts → api/_handler.js'); diff --git a/apps/studio/server/index.ts b/apps/studio/server/index.ts index 6005d1950..a34eb9761 100644 --- a/apps/studio/server/index.ts +++ b/apps/studio/server/index.ts @@ -36,9 +36,37 @@ import { MetadataPlugin } from '@objectstack/metadata'; import { AIServicePlugin } from '@objectstack/service-ai'; import { handle } from '@hono/node-server/vercel'; import { Hono } from 'hono'; +import { cors } from 'hono/cors'; import { createBrokerShim } from '../src/lib/create-broker-shim.js'; import studioConfig from '../objectstack.config.js'; +// --------------------------------------------------------------------------- +// Vercel origin helpers +// --------------------------------------------------------------------------- + +/** + * Collect all Vercel deployment origins from environment variables. + * + * Reused for both: + * - better-auth `trustedOrigins` (CSRF) + * - Hono CORS middleware `origin` allowlist + * + * Centralised to avoid drift between the two allowlists. + */ +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; +} + // --------------------------------------------------------------------------- // Singleton state — persists across warm Vercel invocations // --------------------------------------------------------------------------- @@ -85,17 +113,8 @@ async function ensureKernel(): Promise { ? `https://${process.env.VERCEL_URL}` : 'http://localhost:3000'; - // Collect all Vercel URL variants so better-auth trusts each one - const trustedOrigins: string[] = []; - if (process.env.VERCEL_URL) { - trustedOrigins.push(`https://${process.env.VERCEL_URL}`); - } - if (process.env.VERCEL_BRANCH_URL) { - trustedOrigins.push(`https://${process.env.VERCEL_BRANCH_URL}`); - } - if (process.env.VERCEL_PROJECT_PRODUCTION_URL) { - trustedOrigins.push(`https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`); - } + // Reuse the shared helper so CORS and CSRF allowlists stay in sync + const trustedOrigins = getVercelOrigins(); await kernel.use(new AuthPlugin({ secret: process.env.AUTH_SECRET || 'dev-secret-please-change-in-production-min-32-chars', @@ -218,6 +237,41 @@ async function ensureApp(): Promise { */ const app = new Hono(); +// --------------------------------------------------------------------------- +// CORS middleware +// --------------------------------------------------------------------------- +// Placed on the outer app so preflight (OPTIONS) requests are answered +// immediately, without waiting for the kernel cold-start. This is essential +// when the SPA is loaded from a Vercel temporary/preview domain but the +// API base URL points to a different deployment (cross-origin). +// +// Allowed origins: +// 1. All Vercel deployment URLs exposed via env vars (current deployment) +// 2. Any *.vercel.app subdomain (covers all preview/branch deployments) +// 3. localhost (local development) +// --------------------------------------------------------------------------- + +const vercelOrigins = getVercelOrigins(); + +app.use('*', cors({ + origin: (origin) => { + // Same-origin or non-browser requests (no Origin header) + if (!origin) return origin; + // Explicitly listed Vercel deployment origins + if (vercelOrigins.includes(origin)) return origin; + // Any *.vercel.app subdomain (preview / temp deployments) + if (origin.endsWith('.vercel.app') && origin.startsWith('https://')) return origin; + // Localhost for development + if (/^https?:\/\/localhost(:\d+)?$/.test(origin)) return origin; + // Deny — return empty string so no Access-Control-Allow-Origin is set + return ''; + }, + credentials: true, + allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + allowHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'], + maxAge: 86400, +})); + app.all('*', async (c) => { console.log(`[Vercel] ${c.req.method} ${c.req.url}`); @@ -241,7 +295,7 @@ export default handle(app); /** * Vercel per-function configuration. * - * Picked up by the @vercel/node runtime from the deployed api/index.js bundle. + * Picked up by the @vercel/node runtime from the deployed api/[[...route]].js bundle. * Replaces the top-level "functions" key in vercel.json so there is no * pre-build file-pattern validation against a not-yet-bundled artifact. */ diff --git a/apps/studio/vercel.json b/apps/studio/vercel.json index 0c1e45980..ceb05c1f2 100644 --- a/apps/studio/vercel.json +++ b/apps/studio/vercel.json @@ -6,7 +6,14 @@ "build": { "env": { "VITE_RUNTIME_MODE": "server", - "VITE_SERVER_URL": "https://play.objectstack.ai" + "VITE_SERVER_URL": "" + } + }, + "functions": { + "api/**/*.js": { + "memory": 1024, + "maxDuration": 60, + "includeFiles": "{node_modules/@libsql,node_modules/better-sqlite3}/**" } }, "headers": [ @@ -18,7 +25,7 @@ } ], "rewrites": [ - { "source": "/api/(.*)", "destination": "/api" }, + { "source": "/api/:path*", "destination": "/api/[[...route]]" }, { "source": "/((?!api/).*)", "destination": "/index.html" } ] }