Skip to content

Commit 43ee0d0

Browse files
authored
Merge pull request #1052 from objectstack-ai/copilot/fix-api-routes-returning-html
2 parents dc26ed8 + 0fa5419 commit 43ee0d0

File tree

7 files changed

+186
-23
lines changed

7 files changed

+186
-23
lines changed

CHANGELOG.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3737
match the current monorepo layout.
3838

3939
### Fixed
40+
- **Studio Vercel API routes returning HTML instead of JSON** — Adopted the
41+
same Vercel deployment pattern used by `hotcrm`: committed
42+
`api/[[...route]].js` catch-all route so Vercel detects it pre-build,
43+
switched esbuild output from CJS to ESM (fixes `"type": "module"` conflict),
44+
and changed the bundle output to `api/_handler.js` (a separate file that
45+
the committed wrapper re-exports). This avoids both Vercel's TS
46+
compilation overwriting the bundle (`ERR_MODULE_NOT_FOUND`) and the
47+
"File not found" error from deleting source files during build.
48+
Added `createRequire` banner to the esbuild config so that CJS
49+
dependencies (knex/tarn) can `require()` Node.js built-in modules like
50+
`events` without the "Dynamic require is not supported" error.
51+
Added `functions.includeFiles` in `vercel.json` to include native addons
52+
(`better-sqlite3`, `@libsql/client`) that esbuild cannot bundle.
53+
Added a build step to copy native external modules from the monorepo root
54+
`node_modules/` into the studio's local `node_modules/`, since pnpm's strict
55+
mode (unlike hotcrm's `shamefully-hoist`) doesn't symlink transitive native
56+
dependencies into app-level `node_modules/`.
57+
Updated rewrites to match: `/api/:path*``/api/[[...route]]`.
58+
- **Studio CORS error on Vercel temporary/preview domains** — Changed
59+
`VITE_SERVER_URL` from hardcoded `https://play.objectstack.ai` to `""`
60+
(empty string / same-origin) in `vercel.json` so each deployment — including
61+
previews — calls its own serverless function instead of the production API
62+
cross-origin. Also added Hono CORS middleware to `apps/studio/server/index.ts`
63+
as a safety net for any remaining cross-origin scenarios; dynamically allows
64+
all `*.vercel.app` subdomains, explicitly listed Vercel deployment URLs, and
65+
localhost. Extracted `getVercelOrigins()` helper to keep CORS and
66+
better-auth `trustedOrigins` allowlists in sync.
4067
- **Client test aligned with removed `ai.chat` method** — Updated
4168
`@objectstack/client` test suite to match the current API surface where
4269
`ai.chat()` was removed in favour of the Vercel AI SDK `useChat()` hook.

apps/studio/.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ pnpm-lock.yaml
55
# Build outputs
66
dist
77
build
8-
api
8+
api/_handler.js
9+
api/_handler.js.map
910
*.tsbuildinfo
1011

1112
# Development

apps/studio/api/[[...route]].js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// Vercel Serverless Function — Catch-all API route.
2+
//
3+
// This file MUST be committed to the repository so Vercel can detect it
4+
// as a serverless function during the pre-build phase.
5+
//
6+
// It delegates to the esbuild bundle (`_handler.js`) generated by
7+
// `scripts/bundle-api.mjs` during the Vercel build step. A separate
8+
// bundle file is used (rather than overwriting this file) so that:
9+
// 1. Vercel always finds this committed entry point (no "File not found").
10+
// 2. Vercel does not TypeScript-compile a .ts stub that references
11+
// source files absent at runtime (no ERR_MODULE_NOT_FOUND).
12+
//
13+
// @see ../server/index.ts — the actual server entrypoint
14+
// @see ../scripts/bundle-api.mjs — the esbuild bundler
15+
// @see https://github.com/objectstack-ai/hotcrm/blob/main/vercel.json
16+
17+
export { default, config } from './_handler.js';

apps/studio/scripts/build-vercel.sh

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,19 @@ set -euo pipefail
33

44
# Build script for Vercel deployment of @objectstack/studio.
55
#
6-
# Without outputDirectory, Vercel serves static files from public/.
7-
# Serverless functions are detected from api/ at the project root.
6+
# Follows the same Vercel deployment pattern as hotcrm:
7+
# - api/[[...route]].js is committed to the repo (Vercel detects it pre-build)
8+
# - esbuild bundles server/index.ts → api/_handler.js (self-contained bundle)
9+
# - The committed .js wrapper re-exports from _handler.js at runtime
10+
# - Vite SPA output is copied to public/ for CDN serving
11+
#
12+
# Vercel routing (framework: null, no outputDirectory):
13+
# - Static files: served from public/
14+
# - Serverless functions: detected from api/ at project root
815
#
916
# Steps:
1017
# 1. Turbo build (Vite SPA → dist/)
11-
# 2. Bundle the API serverless function (→ api/index.js)
18+
# 2. Bundle the API serverless function (→ api/_handler.js)
1219
# 3. Copy Vite output to public/ for Vercel CDN serving
1320

1421
echo "[build-vercel] Starting studio build..."
@@ -21,9 +28,43 @@ cd apps/studio
2128
# 2. Bundle API serverless function
2229
node scripts/bundle-api.mjs
2330

24-
# 3. Copy Vite build output to public/ for static file serving
31+
# 3. Copy native/external modules into local node_modules for Vercel packaging.
32+
#
33+
# Unlike hotcrm (which uses shamefully-hoist=true), this monorepo uses pnpm's
34+
# default strict node_modules structure. Transitive native dependencies like
35+
# better-sqlite3 only exist in the monorepo root's node_modules/.pnpm/ virtual
36+
# store — they're NOT symlinked into apps/studio/node_modules/.
37+
#
38+
# The vercel.json includeFiles pattern references node_modules/ relative to
39+
# apps/studio/, so we must copy the actual module files here for Vercel to
40+
# include them in the serverless function's deployment package.
41+
echo "[build-vercel] Copying external native modules to local node_modules..."
42+
for mod in better-sqlite3; do
43+
src="../../node_modules/$mod"
44+
if [ -e "$src" ]; then
45+
dest="node_modules/$mod"
46+
mkdir -p "$(dirname "$dest")"
47+
cp -rL "$src" "$dest"
48+
echo "[build-vercel] ✓ Copied $mod"
49+
else
50+
echo "[build-vercel] ⚠ $mod not found at $src (skipped)"
51+
fi
52+
done
53+
# Copy the @libsql scope (client + its sub-dependencies like core, hrana-client)
54+
if [ -d "../../node_modules/@libsql" ]; then
55+
mkdir -p "node_modules/@libsql"
56+
for pkg in ../../node_modules/@libsql/*/; do
57+
pkgname="$(basename "$pkg")"
58+
cp -rL "$pkg" "node_modules/@libsql/$pkgname"
59+
done
60+
echo "[build-vercel] ✓ Copied @libsql/*"
61+
else
62+
echo "[build-vercel] ⚠ @libsql not found (skipped)"
63+
fi
64+
65+
# 4. Copy Vite build output to public/ for static file serving
2566
rm -rf public
2667
mkdir -p public
2768
cp -r dist/* public/
2869

29-
echo "[build-vercel] Done. Static files in public/, serverless function in api/index.js"
70+
echo "[build-vercel] Done. Static files in public/, serverless function in api/[[...route]].js → api/_handler.js"

apps/studio/scripts/bundle-api.mjs

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,29 @@ await build({
3535
entryPoints: ['server/index.ts'],
3636
bundle: true,
3737
platform: 'node',
38-
format: 'cjs',
38+
format: 'esm',
3939
target: 'es2020',
40-
outfile: 'api/index.js',
40+
outfile: 'api/_handler.js',
4141
sourcemap: true,
4242
external: EXTERNAL,
4343
// Silence warnings about optional/unused require() calls in knex drivers
4444
logOverride: { 'require-resolve-not-external': 'silent' },
45+
// Vercel resolves ESM .js files correctly when "type": "module" is set.
46+
// CJS format would conflict with the project's "type": "module" setting,
47+
// causing Node.js to fail parsing require()/module.exports as ESM syntax.
48+
//
49+
// The createRequire banner provides a real `require` function in the ESM
50+
// scope. esbuild's __require shim (generated for CJS→ESM conversion)
51+
// checks `typeof require !== "undefined"` and uses it when available,
52+
// which fixes "Dynamic require of <builtin> is not supported" errors
53+
// from CJS dependencies like knex/tarn that require() Node.js built-ins.
54+
banner: {
55+
js: [
56+
'// Bundled by esbuild — see scripts/bundle-api.mjs',
57+
'import { createRequire } from "module";',
58+
'const require = createRequire(import.meta.url);',
59+
].join('\n'),
60+
},
4561
});
4662

47-
console.log('[bundle-api] Bundled server/index.ts → api/index.js');
63+
console.log('[bundle-api] Bundled server/index.ts → api/_handler.js');

apps/studio/server/index.ts

Lines changed: 66 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,37 @@ import { MetadataPlugin } from '@objectstack/metadata';
3636
import { AIServicePlugin } from '@objectstack/service-ai';
3737
import { handle } from '@hono/node-server/vercel';
3838
import { Hono } from 'hono';
39+
import { cors } from 'hono/cors';
3940
import { createBrokerShim } from '../src/lib/create-broker-shim.js';
4041
import studioConfig from '../objectstack.config.js';
4142

43+
// ---------------------------------------------------------------------------
44+
// Vercel origin helpers
45+
// ---------------------------------------------------------------------------
46+
47+
/**
48+
* Collect all Vercel deployment origins from environment variables.
49+
*
50+
* Reused for both:
51+
* - better-auth `trustedOrigins` (CSRF)
52+
* - Hono CORS middleware `origin` allowlist
53+
*
54+
* Centralised to avoid drift between the two allowlists.
55+
*/
56+
function getVercelOrigins(): string[] {
57+
const origins: string[] = [];
58+
if (process.env.VERCEL_URL) {
59+
origins.push(`https://${process.env.VERCEL_URL}`);
60+
}
61+
if (process.env.VERCEL_BRANCH_URL) {
62+
origins.push(`https://${process.env.VERCEL_BRANCH_URL}`);
63+
}
64+
if (process.env.VERCEL_PROJECT_PRODUCTION_URL) {
65+
origins.push(`https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`);
66+
}
67+
return origins;
68+
}
69+
4270
// ---------------------------------------------------------------------------
4371
// Singleton state — persists across warm Vercel invocations
4472
// ---------------------------------------------------------------------------
@@ -85,17 +113,8 @@ async function ensureKernel(): Promise<ObjectKernel> {
85113
? `https://${process.env.VERCEL_URL}`
86114
: 'http://localhost:3000';
87115

88-
// Collect all Vercel URL variants so better-auth trusts each one
89-
const trustedOrigins: string[] = [];
90-
if (process.env.VERCEL_URL) {
91-
trustedOrigins.push(`https://${process.env.VERCEL_URL}`);
92-
}
93-
if (process.env.VERCEL_BRANCH_URL) {
94-
trustedOrigins.push(`https://${process.env.VERCEL_BRANCH_URL}`);
95-
}
96-
if (process.env.VERCEL_PROJECT_PRODUCTION_URL) {
97-
trustedOrigins.push(`https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`);
98-
}
116+
// Reuse the shared helper so CORS and CSRF allowlists stay in sync
117+
const trustedOrigins = getVercelOrigins();
99118

100119
await kernel.use(new AuthPlugin({
101120
secret: process.env.AUTH_SECRET || 'dev-secret-please-change-in-production-min-32-chars',
@@ -218,6 +237,41 @@ async function ensureApp(): Promise<Hono> {
218237
*/
219238
const app = new Hono();
220239

240+
// ---------------------------------------------------------------------------
241+
// CORS middleware
242+
// ---------------------------------------------------------------------------
243+
// Placed on the outer app so preflight (OPTIONS) requests are answered
244+
// immediately, without waiting for the kernel cold-start. This is essential
245+
// when the SPA is loaded from a Vercel temporary/preview domain but the
246+
// API base URL points to a different deployment (cross-origin).
247+
//
248+
// Allowed origins:
249+
// 1. All Vercel deployment URLs exposed via env vars (current deployment)
250+
// 2. Any *.vercel.app subdomain (covers all preview/branch deployments)
251+
// 3. localhost (local development)
252+
// ---------------------------------------------------------------------------
253+
254+
const vercelOrigins = getVercelOrigins();
255+
256+
app.use('*', cors({
257+
origin: (origin) => {
258+
// Same-origin or non-browser requests (no Origin header)
259+
if (!origin) return origin;
260+
// Explicitly listed Vercel deployment origins
261+
if (vercelOrigins.includes(origin)) return origin;
262+
// Any *.vercel.app subdomain (preview / temp deployments)
263+
if (origin.endsWith('.vercel.app') && origin.startsWith('https://')) return origin;
264+
// Localhost for development
265+
if (/^https?:\/\/localhost(:\d+)?$/.test(origin)) return origin;
266+
// Deny — return empty string so no Access-Control-Allow-Origin is set
267+
return '';
268+
},
269+
credentials: true,
270+
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
271+
allowHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
272+
maxAge: 86400,
273+
}));
274+
221275
app.all('*', async (c) => {
222276
console.log(`[Vercel] ${c.req.method} ${c.req.url}`);
223277

@@ -241,7 +295,7 @@ export default handle(app);
241295
/**
242296
* Vercel per-function configuration.
243297
*
244-
* Picked up by the @vercel/node runtime from the deployed api/index.js bundle.
298+
* Picked up by the @vercel/node runtime from the deployed api/[[...route]].js bundle.
245299
* Replaces the top-level "functions" key in vercel.json so there is no
246300
* pre-build file-pattern validation against a not-yet-bundled artifact.
247301
*/

apps/studio/vercel.json

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,14 @@
66
"build": {
77
"env": {
88
"VITE_RUNTIME_MODE": "server",
9-
"VITE_SERVER_URL": "https://play.objectstack.ai"
9+
"VITE_SERVER_URL": ""
10+
}
11+
},
12+
"functions": {
13+
"api/**/*.js": {
14+
"memory": 1024,
15+
"maxDuration": 60,
16+
"includeFiles": "{node_modules/@libsql,node_modules/better-sqlite3}/**"
1017
}
1118
},
1219
"headers": [
@@ -18,7 +25,7 @@
1825
}
1926
],
2027
"rewrites": [
21-
{ "source": "/api/(.*)", "destination": "/api" },
28+
{ "source": "/api/:path*", "destination": "/api/[[...route]]" },
2229
{ "source": "/((?!api/).*)", "destination": "/index.html" }
2330
]
2431
}

0 commit comments

Comments
 (0)