Skip to content
Merged
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
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion apps/studio/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ pnpm-lock.yaml
# Build outputs
dist
build
api
api/_handler.js
api/_handler.js.map
*.tsbuildinfo

# Development
Expand Down
17 changes: 17 additions & 0 deletions apps/studio/api/[[...route]].js
Original file line number Diff line number Diff line change
@@ -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';
51 changes: 46 additions & 5 deletions apps/studio/scripts/build-vercel.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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..."
Expand All @@ -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"
22 changes: 19 additions & 3 deletions apps/studio/scripts/bundle-api.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <builtin> 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');
78 changes: 66 additions & 12 deletions apps/studio/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -85,17 +113,8 @@ async function ensureKernel(): Promise<ObjectKernel> {
? `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',
Expand Down Expand Up @@ -218,6 +237,41 @@ async function ensureApp(): Promise<Hono> {
*/
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,
Comment on lines +256 to +269
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CORS allowlist currently permits any https://*.vercel.app origin while also setting credentials: true. This is very broad: any site hosted on Vercel’s shared vercel.app domain could make credentialed cross-origin requests and read responses, which is risky if the API uses cookie-based auth. Consider tightening this to only this project’s preview/branch domains (e.g., derive an allowed pattern from VERCEL_PROJECT_PRODUCTION_URL / VERCEL_URL), or disable credentials for the wildcard case and require explicit origins for credentialed requests. Also note that AuthPlugin is configured with trustedOrigins (CSRF) that do not include this wildcard, so auth endpoints may still fail even if CORS succeeds unless the two allowlists are aligned.

Copilot uses AI. Check for mistakes.
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}`);

Expand All @@ -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.
*/
Expand Down
11 changes: 9 additions & 2 deletions apps/studio/vercel.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand All @@ -18,7 +25,7 @@
}
],
"rewrites": [
{ "source": "/api/(.*)", "destination": "/api" },
{ "source": "/api/:path*", "destination": "/api/[[...route]]" },
{ "source": "/((?!api/).*)", "destination": "/index.html" }
]
}