Fix blank page on self-hosted deploys
The app reads its Base44 app id and backend URL from VITE_BASE44_APP_ID and VITE_BASE44_BACKEND_URL, but neither was set anywhere, so Vite compiled both defaults to undefined and app-params resolved them to null at runtime. On base44's own host these arrive as query params; on a custom domain they never do. A null serverUrl skipped the SDK's default (it only applies to undefined), making every API baseURL relative — requests hit our own nginx, which answered with index.html, and the app rendered against HTML and died. Deploys reported success while serving an empty page. appBaseUrl was never passed either, so login/logout redirects were relative too and bounced between /login and the SPA fallback. redirectToLogin also omits app_id, which base44.app/login 404s on when the request comes from our domain rather than a *.base44.app one, so login now goes through loginWithProvider. Set both vars as build args in the Dockerfile — they must be present at build time, since runtime env vars in Dokploy arrive after Vite has already inlined them. Guard the build against an empty app id and a missing dist/index.html. Surface failures instead of hiding them: an ErrorBoundary around the tree, and a diagnostic panel for auth errors that previously fell through to a crash. In nginx, serve a real 404 for missing /assets/ files. Falling through to the SPA rule returned index.html as text/html, which the browser refuses to run as a module — another silent blank page. Also add /health and stop the SPA fallback from being cached under the requested path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,10 +9,14 @@ lerna-debug.log*
|
|||||||
|
|
||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
# dist
|
# The local dist/ is a developer's last build, not part of the source. Shipping it
|
||||||
|
# into the build context only slows the upload and risks a stale artifact being
|
||||||
|
# picked up if `npm run build` ever fails to overwrite a file.
|
||||||
|
dist
|
||||||
!sdk/dist
|
!sdk/dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
.git
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
|
|||||||
26
Dockerfile
26
Dockerfile
@@ -9,13 +9,31 @@ WORKDIR /app
|
|||||||
# the server. Raise this only if the host gets more RAM.
|
# the server. Raise this only if the host gets more RAM.
|
||||||
ENV NODE_OPTIONS=--max-old-space-size=1024
|
ENV NODE_OPTIONS=--max-old-space-size=1024
|
||||||
|
|
||||||
|
# Vite inlines VITE_* vars into the bundle at build time, so these have to be
|
||||||
|
# present here — setting them as runtime env vars in Dokploy does nothing, the
|
||||||
|
# JS is already compiled by then. .env.* is gitignored, so a build from the repo
|
||||||
|
# has no other source for them: without these the app boots with a null app id,
|
||||||
|
# every API call resolves against our own origin, nginx answers with index.html
|
||||||
|
# and the browser shows a blank page. Defaults match this app so a plain
|
||||||
|
# `docker build` works; override via Dokploy's build args to point elsewhere.
|
||||||
|
ARG VITE_BASE44_APP_ID=68fc6cf01386035c266e7a5d
|
||||||
|
ARG VITE_BASE44_BACKEND_URL=https://base44.app
|
||||||
|
ENV VITE_BASE44_APP_ID=$VITE_BASE44_APP_ID
|
||||||
|
ENV VITE_BASE44_BACKEND_URL=$VITE_BASE44_BACKEND_URL
|
||||||
|
|
||||||
# Install deps from the lockfile first so this layer is reused when only src changes
|
# Install deps from the lockfile first so this layer is reused when only src changes
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci --no-audit --no-fund
|
RUN npm ci --no-audit --no-fund
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# Fail loudly here rather than shipping an image that serves an empty page.
|
||||||
|
RUN test -n "$VITE_BASE44_APP_ID" || (echo "VITE_BASE44_APP_ID is empty — the build would produce a blank app" && exit 1)
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
# The build is only useful if it actually emitted an entry point.
|
||||||
|
RUN test -f dist/index.html || (echo "vite build produced no dist/index.html" && exit 1)
|
||||||
|
|
||||||
# ---- Runtime stage ----
|
# ---- Runtime stage ----
|
||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
|
|
||||||
@@ -27,6 +45,14 @@ COPY --from=build /app/dist /usr/share/nginx/html
|
|||||||
|
|
||||||
COPY nginx.conf /etc/nginx/nginx.conf
|
COPY nginx.conf /etc/nginx/nginx.conf
|
||||||
|
|
||||||
|
# Catch a broken config at build time instead of at container start, where
|
||||||
|
# Dokploy would report the deploy as successful and then serve nothing.
|
||||||
|
RUN nginx -t
|
||||||
|
|
||||||
EXPOSE 80 3000
|
EXPOSE 80 3000
|
||||||
|
|
||||||
|
# Gives Dokploy a real signal that the container is serving, not just running.
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD wget -q --spider http://127.0.0.1/health || exit 1
|
||||||
|
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|||||||
22
nginx.conf
22
nginx.conf
@@ -49,12 +49,29 @@ http {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
# Container liveness for Dokploy/Docker. Kept above the SPA fallback so it
|
||||||
|
# answers with a real status instead of 200-plus-index.html, which would
|
||||||
|
# make any health check pass even with the app completely broken.
|
||||||
|
location = /health {
|
||||||
|
access_log off;
|
||||||
|
add_header Content-Type text/plain;
|
||||||
|
return 200 "ok\n";
|
||||||
|
}
|
||||||
|
|
||||||
# Vite writes content-hashed filenames here, so they can be cached forever.
|
# Vite writes content-hashed filenames here, so they can be cached forever.
|
||||||
# Only add_header is used — combining it with `expires` emits two
|
# Only add_header is used — combining it with `expires` emits two
|
||||||
# conflicting Cache-Control headers.
|
# conflicting Cache-Control headers.
|
||||||
|
#
|
||||||
|
# try_files ending in =404 is deliberate: without it a stale index.html
|
||||||
|
# asking for an asset from a previous deploy would fall through to the
|
||||||
|
# SPA rule below and receive index.html with a 200 and a text/html type.
|
||||||
|
# The browser refuses to execute that as a module and the page renders
|
||||||
|
# empty with only a MIME error in the console — a real 404 is far easier
|
||||||
|
# to diagnose and lets a reload recover.
|
||||||
location /assets/ {
|
location /assets/ {
|
||||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
access_log off;
|
access_log off;
|
||||||
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
# index.html must be revalidated every time or deploys won't take effect
|
# index.html must be revalidated every time or deploys won't take effect
|
||||||
@@ -65,6 +82,11 @@ http {
|
|||||||
location / {
|
location / {
|
||||||
# This line forces Nginx to pass routing back to React Router
|
# This line forces Nginx to pass routing back to React Router
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
|
|
||||||
|
# The fallback is served under the requested path, so the `= /index.html`
|
||||||
|
# rule above never applies to it. Without this a proxy or browser can
|
||||||
|
# cache the old HTML and keep loading assets that no longer exist.
|
||||||
|
add_header Cache-Control "no-cache";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
40
src/App.jsx
40
src/App.jsx
@@ -9,6 +9,8 @@ import { BrowserRouter as Router, Route, Routes, useLocation } from 'react-route
|
|||||||
import { setupIframeMessaging } from './lib/iframe-messaging';
|
import { setupIframeMessaging } from './lib/iframe-messaging';
|
||||||
import PageNotFound from './lib/PageNotFound';
|
import PageNotFound from './lib/PageNotFound';
|
||||||
import { AuthProvider, useAuth } from '@/lib/AuthContext';
|
import { AuthProvider, useAuth } from '@/lib/AuthContext';
|
||||||
|
import ErrorBoundary from '@/lib/ErrorBoundary';
|
||||||
|
import { appParams } from '@/lib/app-params';
|
||||||
import UserNotRegisteredError from '@/components/UserNotRegisteredError';
|
import UserNotRegisteredError from '@/components/UserNotRegisteredError';
|
||||||
import FeatureReport from './pages/FeatureReport';
|
import FeatureReport from './pages/FeatureReport';
|
||||||
import LandingPage from './pages/LandingPage';
|
import LandingPage from './pages/LandingPage';
|
||||||
@@ -70,6 +72,22 @@ const AuthenticatedApp = () => {
|
|||||||
navigateToLogin();
|
navigateToLogin();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Anything else (bad/missing app id, unreachable backend, network failure)
|
||||||
|
// used to fall straight through to the app, which then crashed on the empty
|
||||||
|
// data and left a blank page. Say what happened instead.
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-slate-50 p-6">
|
||||||
|
<div className="max-w-lg w-full bg-white border border-slate-200 rounded-lg shadow-sm p-6">
|
||||||
|
<h1 className="text-lg font-semibold text-slate-900">Unable to load the app</h1>
|
||||||
|
<p className="mt-2 text-sm text-slate-600">{authError.message || 'The Base44 backend could not be reached.'}</p>
|
||||||
|
<p className="mt-4 text-xs text-slate-500">
|
||||||
|
App ID: <code>{String(appParams.appId)}</code><br />
|
||||||
|
Backend: <code>{String(appParams.serverUrl)}</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render the main app
|
// Render the main app
|
||||||
@@ -126,16 +144,18 @@ const AuthenticatedApp = () => {
|
|||||||
function App() {
|
function App() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<ErrorBoundary>
|
||||||
<QueryClientProvider client={queryClientInstance}>
|
<AuthProvider>
|
||||||
<Router>
|
<QueryClientProvider client={queryClientInstance}>
|
||||||
<NavigationTracker />
|
<Router>
|
||||||
<AuthenticatedApp />
|
<NavigationTracker />
|
||||||
</Router>
|
<AuthenticatedApp />
|
||||||
<Toaster />
|
</Router>
|
||||||
<VisualEditAgent />
|
<Toaster />
|
||||||
</QueryClientProvider>
|
<VisualEditAgent />
|
||||||
</AuthProvider>
|
</QueryClientProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
</ErrorBoundary>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,15 @@ import { appParams } from '@/lib/app-params';
|
|||||||
|
|
||||||
const { appId, serverUrl, token, functionsVersion } = appParams;
|
const { appId, serverUrl, token, functionsVersion } = appParams;
|
||||||
|
|
||||||
//Create a client with authentication required
|
// appBaseUrl is what the SDK prefixes onto its login/logout redirects. Left unset
|
||||||
|
// it normalises to "" and those redirects become relative — on a self-hosted
|
||||||
|
// domain that sends the browser to /login on our own nginx, which serves
|
||||||
|
// index.html, which bootstraps the app, which redirects again: a loop that shows
|
||||||
|
// as a blank page. Pointing it at Base44 sends auth where it actually lives.
|
||||||
export const base44 = createClient({
|
export const base44 = createClient({
|
||||||
appId,
|
appId,
|
||||||
serverUrl,
|
serverUrl,
|
||||||
|
appBaseUrl: serverUrl,
|
||||||
token,
|
token,
|
||||||
functionsVersion,
|
functionsVersion,
|
||||||
requiresAuth: false
|
requiresAuth: false
|
||||||
|
|||||||
@@ -124,8 +124,11 @@ export const AuthProvider = ({ children }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const navigateToLogin = () => {
|
const navigateToLogin = () => {
|
||||||
// Use the SDK's redirectToLogin method
|
// redirectToLogin() posts to `${appBaseUrl}/login` with no app_id, so Base44
|
||||||
base44.auth.redirectToLogin(window.location.href);
|
// cannot tell which app is asking when the request comes from our own domain
|
||||||
|
// instead of a *.base44.app one. loginWithProvider() hits the same login flow
|
||||||
|
// but carries app_id in the query, which is what a self-hosted build needs.
|
||||||
|
base44.auth.loginWithProvider('google', window.location.href);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
52
src/lib/ErrorBoundary.jsx
Normal file
52
src/lib/ErrorBoundary.jsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Catches render-time crashes so they surface as a readable message instead of a
|
||||||
|
* white screen. React unmounts the whole tree when an error escapes, which is
|
||||||
|
* indistinguishable from "the deploy didn't work" — this makes the difference
|
||||||
|
* visible without opening devtools, which matters most on a hosted build where
|
||||||
|
* the stack is minified and nobody is watching the console.
|
||||||
|
*/
|
||||||
|
class ErrorBoundary extends React.Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = { error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error) {
|
||||||
|
return { error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error, info) {
|
||||||
|
console.error('Application crashed:', error, info?.componentStack);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (!this.state.error) {
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-slate-50 p-6">
|
||||||
|
<div className="max-w-lg w-full bg-white border border-slate-200 rounded-lg shadow-sm p-6">
|
||||||
|
<h1 className="text-lg font-semibold text-slate-900">Something went wrong</h1>
|
||||||
|
<p className="mt-2 text-sm text-slate-600">
|
||||||
|
The application failed to render. The details below are also in the browser console.
|
||||||
|
</p>
|
||||||
|
<pre className="mt-4 p-3 bg-slate-100 rounded text-xs text-slate-800 overflow-auto max-h-64 whitespace-pre-wrap">
|
||||||
|
{String(this.state.error?.stack || this.state.error)}
|
||||||
|
</pre>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="mt-4 px-4 py-2 text-sm font-medium text-white bg-slate-900 rounded hover:bg-slate-700"
|
||||||
|
>
|
||||||
|
Reload
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ErrorBoundary;
|
||||||
@@ -34,10 +34,18 @@ const getAppParamValue = (paramName, { defaultValue = undefined, removeFromUrl =
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Base44's own host. On a base44-hosted app these arrive as ?app_id=/?server_url=
|
||||||
|
// query params, but a self-hosted build (Docker/Dokploy on a custom domain) never
|
||||||
|
// gets them, so the build-time env vars below are the only source. If both are
|
||||||
|
// missing the SDK falls back to a null serverUrl, every request becomes a
|
||||||
|
// same-origin relative URL, nginx answers it with index.html, and the app renders
|
||||||
|
// a blank page — so serverUrl must never be allowed to end up null.
|
||||||
|
const DEFAULT_SERVER_URL = "https://base44.app";
|
||||||
|
|
||||||
const getAppParams = () => {
|
const getAppParams = () => {
|
||||||
return {
|
return {
|
||||||
appId: getAppParamValue("app_id", { defaultValue: import.meta.env.VITE_BASE44_APP_ID }),
|
appId: getAppParamValue("app_id", { defaultValue: import.meta.env.VITE_BASE44_APP_ID }),
|
||||||
serverUrl: getAppParamValue("server_url", { defaultValue: import.meta.env.VITE_BASE44_BACKEND_URL }),
|
serverUrl: getAppParamValue("server_url", { defaultValue: import.meta.env.VITE_BASE44_BACKEND_URL }) || DEFAULT_SERVER_URL,
|
||||||
token: getAppParamValue("access_token", { removeFromUrl: true }),
|
token: getAppParamValue("access_token", { removeFromUrl: true }),
|
||||||
fromUrl: getAppParamValue("from_url", { defaultValue: window.location.href }),
|
fromUrl: getAppParamValue("from_url", { defaultValue: window.location.href }),
|
||||||
functionsVersion: getAppParamValue("functions_version"),
|
functionsVersion: getAppParamValue("functions_version"),
|
||||||
|
|||||||
Reference in New Issue
Block a user