diff --git a/.dockerignore b/.dockerignore index f8da17c..11c8dbe 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,10 +9,14 @@ lerna-debug.log* 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 dist-ssr *.local +.git # Editor directories and files .vscode/* diff --git a/Dockerfile b/Dockerfile index e822330..1529b4d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,13 +9,31 @@ WORKDIR /app # the server. Raise this only if the host gets more RAM. 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 COPY package.json package-lock.json ./ RUN npm ci --no-audit --no-fund 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 +# 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 ---- FROM nginx:alpine @@ -27,6 +45,14 @@ COPY --from=build /app/dist /usr/share/nginx/html 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 +# 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;"] diff --git a/nginx.conf b/nginx.conf index 29af0b4..1ad25cb 100644 --- a/nginx.conf +++ b/nginx.conf @@ -49,12 +49,29 @@ http { root /usr/share/nginx/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. # Only add_header is used — combining it with `expires` emits two # 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/ { add_header Cache-Control "public, max-age=31536000, immutable"; access_log off; + try_files $uri =404; } # index.html must be revalidated every time or deploys won't take effect @@ -65,6 +82,11 @@ http { location / { # This line forces Nginx to pass routing back to React Router 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"; } } } diff --git a/src/App.jsx b/src/App.jsx index f637a4d..b52371d 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -9,6 +9,8 @@ import { BrowserRouter as Router, Route, Routes, useLocation } from 'react-route import { setupIframeMessaging } from './lib/iframe-messaging'; import PageNotFound from './lib/PageNotFound'; import { AuthProvider, useAuth } from '@/lib/AuthContext'; +import ErrorBoundary from '@/lib/ErrorBoundary'; +import { appParams } from '@/lib/app-params'; import UserNotRegisteredError from '@/components/UserNotRegisteredError'; import FeatureReport from './pages/FeatureReport'; import LandingPage from './pages/LandingPage'; @@ -70,6 +72,22 @@ const AuthenticatedApp = () => { navigateToLogin(); 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 ( +
+
+

Unable to load the app

+

{authError.message || 'The Base44 backend could not be reached.'}

+

+ App ID: {String(appParams.appId)}
+ Backend: {String(appParams.serverUrl)} +

+
+
+ ); } // Render the main app @@ -126,16 +144,18 @@ const AuthenticatedApp = () => { function App() { return ( - - - - - - - - - - + + + + + + + + + + + + ) } diff --git a/src/api/base44Client.js b/src/api/base44Client.js index 7826ba5..3cc2ce1 100644 --- a/src/api/base44Client.js +++ b/src/api/base44Client.js @@ -3,10 +3,15 @@ import { appParams } from '@/lib/app-params'; 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({ appId, serverUrl, + appBaseUrl: serverUrl, token, functionsVersion, requiresAuth: false diff --git a/src/lib/AuthContext.jsx b/src/lib/AuthContext.jsx index e7c8706..69a6164 100644 --- a/src/lib/AuthContext.jsx +++ b/src/lib/AuthContext.jsx @@ -124,8 +124,11 @@ export const AuthProvider = ({ children }) => { }; const navigateToLogin = () => { - // Use the SDK's redirectToLogin method - base44.auth.redirectToLogin(window.location.href); + // redirectToLogin() posts to `${appBaseUrl}/login` with no app_id, so Base44 + // 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 ( diff --git a/src/lib/ErrorBoundary.jsx b/src/lib/ErrorBoundary.jsx new file mode 100644 index 0000000..de9017c --- /dev/null +++ b/src/lib/ErrorBoundary.jsx @@ -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 ( +
+
+

Something went wrong

+

+ The application failed to render. The details below are also in the browser console. +

+
+            {String(this.state.error?.stack || this.state.error)}
+          
+ +
+
+ ); + } +} + +export default ErrorBoundary; diff --git a/src/lib/app-params.js b/src/lib/app-params.js index 9db8313..77f4b20 100644 --- a/src/lib/app-params.js +++ b/src/lib/app-params.js @@ -34,10 +34,18 @@ const getAppParamValue = (paramName, { defaultValue = undefined, removeFromUrl = 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 = () => { return { 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 }), fromUrl: getAppParamValue("from_url", { defaultValue: window.location.href }), functionsVersion: getAppParamValue("functions_version"),