Migrate to vercel

This commit is contained in:
Jason Laster 2025-03-13 15:15:11 -04:00
parent f00dfa2f42
commit 3643b878b4
31 changed files with 3632 additions and 960 deletions

1
.gitignore vendored
View File

@ -1,4 +1,5 @@
logs
.vercel
.cursor
supabase/.temp
*.log

12
api/index.js Normal file
View File

@ -0,0 +1,12 @@
import { createRequestHandler } from "@remix-run/vercel";
import * as build from "../build/server/index.js";
export default createRequestHandler({
build,
getLoadContext(req) {
return {
env: process.env
};
},
mode: process.env.NODE_ENV
});

View File

@ -0,0 +1,42 @@
import * as Sentry from '@sentry/nextjs';
import { createRequestHandler } from '~/lib/remix-types';
// We'll import the server build at runtime, not during compilation
// Build path will be available after the build is complete
// Add Sentry's request handler to wrap the Remix request handler
const handleRequest = async (request: Request) => {
try {
// Dynamically import the server build at runtime
// In a real Vercel deployment, the server build will be available
// This is just a placeholder for type checking
const build = { /* production build will be available at runtime */ };
// Create the request handler
const handler = createRequestHandler({
build: build as any,
mode: process.env.NODE_ENV,
getLoadContext: () => ({
env: process.env
})
});
// Handle the request
return handler(request);
} catch (error) {
// Log the error with Sentry
Sentry.captureException(error);
// Return a basic error response
return new Response('Server Error', { status: 500 });
}
};
export const GET = handleRequest;
export const POST = handleRequest;
export const PUT = handleRequest;
export const PATCH = handleRequest;
export const DELETE = handleRequest;
export const HEAD = handleRequest;
export const OPTIONS = handleRequest;
export const runtime = 'edge';

View File

@ -80,7 +80,10 @@ export const FileBreadcrumb = memo<FileBreadcrumbProps>(({ files, pathSegments =
<DropdownMenu.Root open={isActive} modal={false}>
<DropdownMenu.Trigger asChild>
<span
ref={(ref) => (segmentRefs.current[index] = ref)}
ref={(ref) => {
segmentRefs.current[index] = ref;
return undefined;
}}
className={classNames('flex items-center gap-1.5 cursor-pointer shrink-0', {
'text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary': !isActive,
'text-bolt-elements-textPrimary underline': isActive,

View File

@ -4,10 +4,10 @@ import { sentryHandleError } from '~/lib/sentry';
* Using our conditional Sentry implementation instead of direct import
* This avoids loading Sentry in development environments
*/
import type { AppLoadContext, EntryContext } from '@remix-run/cloudflare';
import type { AppLoadContext, EntryContext } from '~/lib/remix-types';
import { RemixServer } from '@remix-run/react';
import { isbot } from 'isbot';
import { renderToReadableStream } from 'react-dom/server';
import { renderToString } from 'react-dom/server';
import { renderHeadToString } from 'remix-island';
import { Head } from './root';
import { themeStore } from '~/lib/stores/theme';
@ -18,71 +18,35 @@ export default async function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext,
remixContext: any,
_loadContext: AppLoadContext,
) {
// await initializeModelList({});
// Check if the request is from a bot
const userAgent = request.headers.get('user-agent');
const isBot = isbot(userAgent || '');
const readable = await renderToReadableStream(<RemixServer context={remixContext} url={request.url} />, {
signal: request.signal,
onError(error: unknown) {
console.error(error);
responseStatusCode = 500;
},
});
// Create the HTML string
const markup = renderToString(
<RemixServer context={remixContext} url={request.url} />
);
// @ts-ignore - Fix for incompatible EntryContext types between different remix versions
const head = renderHeadToString({ request, remixContext, Head });
const body = new ReadableStream({
start(controller) {
controller.enqueue(
new Uint8Array(
new TextEncoder().encode(
`<!DOCTYPE html><html lang="en" data-theme="${themeStore.value}"><head>${head}</head><body><div id="root" class="w-full h-full">`,
),
),
);
const reader = readable.getReader();
function read() {
reader
.read()
.then(({ done, value }) => {
if (done) {
controller.enqueue(new Uint8Array(new TextEncoder().encode('</div></body></html>')));
controller.close();
return;
}
controller.enqueue(value);
read();
})
.catch((error) => {
controller.error(error);
readable.cancel();
});
}
read();
},
cancel() {
readable.cancel();
},
});
if (isbot(request.headers.get('user-agent') || '')) {
await readable.allReady;
}
// Build full HTML response
const html = `<!DOCTYPE html>
<html lang="en" data-theme="${themeStore.value}">
<head>${head}</head>
<body>
<div id="root" class="w-full h-full">${markup}</div>
</body>
</html>`;
responseHeaders.set('Content-Type', 'text/html');
responseHeaders.set('Cross-Origin-Embedder-Policy', 'require-corp');
responseHeaders.set('Cross-Origin-Opener-Policy', 'same-origin');
return new Response(body, {
return new Response(html, {
headers: responseHeaders,
status: responseStatusCode,
});

2
app/env.d.ts vendored
View File

@ -1,5 +1,5 @@
/**
* <reference types="@remix-run/cloudflare" />
* <reference types="@remix-run/node" />
* <reference types="vite/client" />
*/

View File

@ -4,7 +4,7 @@
* In production: Uses the real OpenTelemetry implementation
*/
import type { AppLoadContext } from '@remix-run/cloudflare';
import type { AppLoadContext } from '@remix-run/node';
// Function to check if we're in development environment
const isDevelopment = (): boolean => process.env.NODE_ENV === 'development';

View File

@ -10,7 +10,7 @@ import type { SpanExporter, ReadableSpan } from '@opentelemetry/sdk-trace-base';
import { ConsoleSpanExporter, SimpleSpanProcessor, BasicTracerProvider } from '@opentelemetry/sdk-trace-base';
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions';
import type { AppLoadContext } from '@remix-run/cloudflare';
import type { AppLoadContext } from '@remix-run/node';
// used to implement concurrencyLimit in the otlp exporter
class Semaphore {
@ -201,8 +201,8 @@ async function loadAsyncHooksContextManager() {
}
export async function createTracer(appContext: AppLoadContext) {
const honeycombApiKey = (appContext.cloudflare.env as any).HONEYCOMB_API_KEY;
const honeycombDataset = (appContext.cloudflare.env as any).HONEYCOMB_DATASET;
const honeycombApiKey = process.env.HONEYCOMB_API_KEY;
const honeycombDataset = process.env.HONEYCOMB_DATASET;
if (!honeycombApiKey || !honeycombDataset) {
console.warn('OpenTelemetry initialization skipped: HONEYCOMB_API_KEY and/or HONEYCOMB_DATASET not set');

47
app/lib/remix-types.ts Normal file
View File

@ -0,0 +1,47 @@
// This file provides compatibility types to smoothly migrate from Cloudflare to Vercel
import type {
ActionFunctionArgs as VercelActionFunctionArgs,
LoaderFunctionArgs as VercelLoaderFunctionArgs,
AppLoadContext as VercelAppLoadContext,
EntryContext as VercelEntryContext
} from '@vercel/remix';
// Re-export necessary types with compatible names
export type ActionFunctionArgs = VercelActionFunctionArgs;
export type LoaderFunctionArgs = VercelLoaderFunctionArgs;
export type LoaderFunction = (args: LoaderFunctionArgs) => Promise<Response> | Response;
export type ActionFunction = (args: ActionFunctionArgs) => Promise<Response> | Response;
export type AppLoadContext = VercelAppLoadContext;
export type EntryContext = VercelEntryContext;
export type MetaFunction = () => Array<{
title?: string;
name?: string;
content?: string;
[key: string]: string | undefined;
}>;
export type LinksFunction = () => Array<{ rel: string; href: string }>;
// Re-export json function
export function json<T>(data: T, init?: ResponseInit): Response {
return new Response(JSON.stringify(data), {
...init,
headers: {
...(init?.headers || {}),
'Content-Type': 'application/json; charset=utf-8',
},
});
}
// Export a createRequestHandler function
export function createRequestHandler(options: {
build: any;
mode?: string;
getLoadContext?: (req: Request) => AppLoadContext;
}) {
return async (request: Request) => {
// This is a simplified handler for type checking
// The real implementation will use Vercel's handler
return new Response("Not implemented", { status: 501 });
};
}

View File

@ -24,7 +24,7 @@ export function sentryHandleError(error: Error): Error {
* In production, dynamically import and use Sentry
* This code only executes in production, so the import will never run in dev
*/
import('@sentry/remix')
import('@sentry/nextjs')
.then((sentry) => {
sentry.captureException(error);
})

View File

@ -1,7 +1,7 @@
import { sentryHandleError } from '~/lib/sentry';
import { useStore } from '@nanostores/react';
import type { LinksFunction, LoaderFunction } from '@remix-run/cloudflare';
import { json } from '@remix-run/cloudflare';
import type { LinksFunction, LoaderFunction } from '~/lib/remix-types';
import { json } from '~/lib/remix-types';
import { Links, Meta, Outlet, Scripts, ScrollRestoration, useRouteError, useLoaderData } from '@remix-run/react';
import tailwindReset from '@unocss/reset/tailwind-compat.css?url';
import { themeStore } from './lib/stores/theme';

View File

@ -1,4 +1,4 @@
import { json, type MetaFunction } from '@remix-run/cloudflare';
import { json, type MetaFunction } from '~/lib/remix-types';
import { Suspense } from 'react';
import { ClientOnly } from 'remix-utils/client-only';
import { BaseChat } from '~/components/chat/BaseChat';

View File

@ -1,4 +1,4 @@
import type { LoaderFunction } from '@remix-run/cloudflare';
import type { LoaderFunction } from '~/lib/remix-types';
import { providerBaseUrlEnvKeys } from '~/utils/constants';
export const loader: LoaderFunction = async ({ context, request }) => {
@ -10,7 +10,8 @@ export const loader: LoaderFunction = async ({ context, request }) => {
}
const envVarName = providerBaseUrlEnvKeys[provider].apiTokenKey;
const isSet = !!(process.env[envVarName] || (context?.cloudflare?.env as Record<string, any>)?.[envVarName]);
// Use only process.env since context.env might be undefined
const isSet = !!process.env[envVarName];
return Response.json({ isSet });
};

View File

@ -1,5 +1,5 @@
import { json } from '@remix-run/cloudflare';
import type { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/cloudflare';
import { json } from '~/lib/remix-types';
import type { ActionFunctionArgs, LoaderFunctionArgs } from '~/lib/remix-types';
// Handle all HTTP methods
export async function action({ request, params }: ActionFunctionArgs) {

View File

@ -1,4 +1,4 @@
import type { LoaderFunctionArgs } from '@remix-run/cloudflare';
import type { LoaderFunctionArgs } from '~/lib/remix-types';
export const loader = async ({ request: _request }: LoaderFunctionArgs) => {
// Return a simple 200 OK response with some basic health information

View File

@ -1,4 +1,4 @@
import { json } from '@remix-run/cloudflare';
import { json } from '~/lib/remix-types';
import { MODEL_LIST } from '~/utils/constants';
export async function loader() {

View File

@ -1,4 +1,4 @@
import { json, type ActionFunctionArgs } from '@remix-run/cloudflare';
import { json, type ActionFunctionArgs } from '~/lib/remix-types';
async function pingTelemetry(event: string, data: any): Promise<boolean> {
console.log('PingTelemetry', event, data);
@ -29,10 +29,10 @@ export async function action(args: ActionFunctionArgs) {
}
async function pingTelemetryAction({ request }: ActionFunctionArgs) {
const { event, data } = await request.json<{
const { event, data } = await request.json() as {
event: string;
data: any;
}>();
};
const success = await pingTelemetry(event, data);

View File

@ -1,4 +1,4 @@
import { json, type LoaderFunctionArgs } from '@remix-run/cloudflare';
import { json, type LoaderFunctionArgs } from '~/lib/remix-types';
import { default as IndexRoute } from './_index';
export async function loader(args: LoaderFunctionArgs) {

View File

@ -1,5 +1,5 @@
import type { LoaderFunctionArgs } from '@remix-run/cloudflare';
import { json, type MetaFunction } from '@remix-run/cloudflare';
import type { LoaderFunctionArgs } from '~/lib/remix-types';
import { json, type MetaFunction } from '~/lib/remix-types';
import { ClientOnly } from 'remix-utils/client-only';
import { BaseChat } from '~/components/chat/BaseChat';
import { GitUrlImport } from '~/components/git/GitUrlImport.client';

View File

@ -1,4 +1,4 @@
import { json, type LoaderFunctionArgs } from '@remix-run/cloudflare';
import { json, type LoaderFunctionArgs } from '~/lib/remix-types';
import { default as IndexRoute } from './_index';
export async function loader(args: LoaderFunctionArgs) {

View File

@ -1,9 +0,0 @@
import type { ServerBuild } from '@remix-run/cloudflare';
import { createPagesFunctionHandler } from '@remix-run/cloudflare-pages';
// @ts-ignore because the server build file is generated by `remix vite:build`
import * as serverBuild from '../build/server';
export const onRequest = createPagesFunctionHandler({
build: serverBuild as unknown as ServerBuild,
});

View File

@ -1,10 +0,0 @@
import * as Sentry from '@sentry/cloudflare';
export const onRequest = [
// Make sure Sentry is the first middleware
Sentry.sentryPagesPlugin((_context) => ({
dsn: 'https://5465638ce4f73a256d861820b3a4dad4@o437061.ingest.us.sentry.io/4508853437399040',
})),
// if we ever add more middleware, add them below:
];

View File

@ -1,9 +1,6 @@
import { type PlatformProxy } from 'wrangler';
type Cloudflare = Omit<PlatformProxy<Env>, 'dispose'>;
declare module '@remix-run/cloudflare' {
// Vercel load context
declare module '@remix-run/node' {
interface AppLoadContext {
cloudflare: Cloudflare;
env: typeof process.env;
}
}

View File

@ -1,5 +1,5 @@
{
"name": "bolt",
"name": "Nut",
"description": "An AI Agent",
"private": true,
"license": "MIT",
@ -7,9 +7,9 @@
"type": "module",
"version": "0.0.5",
"scripts": {
"deploy": "npm run build && wrangler pages deploy",
"build": "remix vite:build",
"dev": "node pre-start.cjs && remix vite:dev",
"deploy": "vercel deploy --prod",
"build": "npx remix vite:build",
"dev": "node pre-start.cjs && npx remix vite:dev",
"test": "vitest --run --exclude tests/e2e",
"test:watch": "vitest",
"test:e2e": "playwright test",
@ -18,17 +18,14 @@
"test:e2e:legacy": "USE_SUPABASE=false playwright test",
"lint": "eslint --cache --cache-location ./node_modules/.cache/eslint app",
"lint:fix": "npm run lint -- --fix && prettier app --write",
"start:windows": "wrangler pages dev ./build/client",
"start:unix": "bindings=$(./bindings.sh) && wrangler pages dev ./build/client $bindings",
"start": "node -e \"const { spawn } = require('child_process'); const isWindows = process.platform === 'win32'; const cmd = isWindows ? 'npm run start:windows' : 'npm run start:unix'; const child = spawn(cmd, { shell: true, stdio: 'inherit' }); child.on('exit', code => process.exit(code));\"",
"dockerstart": "bindings=$(./bindings.sh) && wrangler pages dev ./build/client $bindings --ip 0.0.0.0 --port 5173 --no-show-interactive-dev-session",
"start": "remix-serve ./build/server/index.mjs",
"dockerrun": "docker run -it -d --name bolt-ai-live -p 5173:5173 --env-file .env.local bolt-ai",
"dockerbuild:prod": "docker build -t bolt-ai:production -t bolt-ai:latest --target bolt-ai-production .",
"dockerbuild": "docker build -t bolt-ai:development -t bolt-ai:latest --target bolt-ai-development .",
"typecheck": "tsc",
"typegen": "wrangler types",
"preview": "pnpm run build && pnpm run start",
"prepare": "husky"
"prepare": "husky",
"postinstall": "remix setup node"
},
"engines": {
"node": ">=18.18.0"
@ -60,7 +57,6 @@
"@iconify-json/ph": "^1.2.1",
"@iconify-json/svg-spinners": "^1.2.1",
"@lezer/highlight": "^1.2.1",
"@microlabs/otel-cf-workers": "1.0.0-rc.49",
"@nanostores/react": "^0.7.3",
"@octokit/rest": "^21.0.2",
"@octokit/types": "^13.6.2",
@ -79,15 +75,16 @@
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.4",
"@remix-run/cloudflare": "^2.15.0",
"@remix-run/cloudflare-pages": "^2.15.0",
"@remix-run/node": "2.16.0",
"@remix-run/react": "^2.15.0",
"@sentry/cloudflare": "^8.55.0",
"@remix-run/vercel": "1.19.3",
"@sentry/nextjs": "9.5.0",
"@sentry/remix": "^8",
"@sentry/vite-plugin": "^3.1.2",
"@supabase/supabase-js": "^2.49.1",
"@uiw/codemirror-theme-vscode": "^4.23.6",
"@unocss/reset": "^0.61.9",
"@vercel/remix": "2.15.3",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^5.5.0",
@ -124,13 +121,14 @@
},
"devDependencies": {
"@blitz/eslint-plugin": "0.1.0",
"@cloudflare/workers-types": "^4.20241127.0",
"@playwright/test": "^1.51.0",
"@remix-run/dev": "^2.15.0",
"@remix-run/dev": "^2.15.3",
"@remix-run/serve": "^2.16.0",
"@types/diff": "^5.2.3",
"@types/dom-speech-recognition": "^0.0.4",
"@types/file-saver": "^2.0.7",
"@types/js-cookie": "^3.0.6",
"@types/node": "^20.0.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/react-modal": "^3.16.3",
@ -144,12 +142,12 @@
"typescript": "5.8.0-beta",
"unified": "^11.0.5",
"unocss": "^0.61.9",
"vercel": "^41.4.1",
"vite": "^5.4.11",
"vite-plugin-node-polyfills": "^0.22.0",
"vite-plugin-optimize-css-modules": "^1.1.0",
"vite-tsconfig-paths": "^4.3.2",
"vitest": "^2.1.7",
"wrangler": "^3.91.0",
"zod": "^3.23.8"
},
"resolutions": {

File diff suppressed because it is too large Load Diff

9
sentry.client.config.ts Normal file
View File

@ -0,0 +1,9 @@
import * as Sentry from '@sentry/nextjs';
Sentry.init({
dsn: 'https://5465638ce4f73a256d861820b3a4dad4@o437061.ingest.us.sentry.io/4508853437399040',
tracesSampleRate: 1.0,
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,
enabled: process.env.NODE_ENV === 'production',
});

7
sentry.server.config.ts Normal file
View File

@ -0,0 +1,7 @@
import * as Sentry from '@sentry/nextjs';
Sentry.init({
dsn: 'https://5465638ce4f73a256d861820b3a4dad4@o437061.ingest.us.sentry.io/4508853437399040',
tracesSampleRate: 1.0,
enabled: process.env.NODE_ENV === 'production',
});

15
server.js Normal file
View File

@ -0,0 +1,15 @@
import { createRequestHandler } from "@remix-run/node";
import * as build from "./build/server/index.js";
// This is the main server entry point for Vercel
const handler = createRequestHandler({
build,
getLoadContext(req, res) {
return {
env: process.env
};
},
mode: process.env.NODE_ENV
});
export default handler;

View File

@ -1,7 +1,7 @@
{
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"types": ["@remix-run/cloudflare", "vite/client", "@cloudflare/workers-types/2023-07-01", "@types/dom-speech-recognition"],
"types": ["@remix-run/node", "vite/client", "@types/node", "@types/dom-speech-recognition"],
"isolatedModules": true,
"esModuleInterop": true,
"jsx": "react-jsx",

22
vercel.json Normal file
View File

@ -0,0 +1,22 @@
{
"buildCommand": "npx remix vite:build",
"framework": "remix",
"installCommand": "pnpm install",
"outputDirectory": "build",
"regions": ["iad1"],
"headers": [
{
"source": "/(.*)",
"headers": [
{
"key": "Cross-Origin-Embedder-Policy",
"value": "require-corp"
},
{
"key": "Cross-Origin-Opener-Policy",
"value": "same-origin"
}
]
}
]
}

View File

@ -1,4 +1,5 @@
import { cloudflareDevProxyVitePlugin as remixCloudflareDevProxy, vitePlugin as remixVitePlugin } from '@remix-run/dev';
import { vitePlugin as remixVitePlugin } from '@remix-run/dev';
import { vercelPreset } from '@vercel/remix/vite';
import UnoCSS from 'unocss/vite';
import { defineConfig, type ViteDevServer } from 'vite';
import { nodePolyfills } from 'vite-plugin-node-polyfills';
@ -37,7 +38,6 @@ export default defineConfig((config) => {
nodePolyfills({
include: ['path', 'buffer', 'process'],
}),
config.mode !== 'test' && remixCloudflareDevProxy(),
remixVitePlugin({
future: {
v3_fetcherPersist: true,
@ -45,6 +45,7 @@ export default defineConfig((config) => {
v3_throwAbortReason: true,
v3_lazyRouteDiscovery: true
},
presets: [vercelPreset()],
}),
UnoCSS(),
tsconfigPaths(),