Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Changed

- `base44 dev` now fronts the app's own dev server: it serves the whole app on one origin, answers `/api/apps` itself and forwards everything else — the app's own `/api` routes included — to the dev server, carrying `Base44-App-Id`, `Base44-Api-Url` and a service-role credential, and dropping the caller's copies of those headers. Full-stack apps (SSR pages, server functions) can build an SDK client with `createClientFromRequest` locally, the way they do in production. Open the URL the command prints instead of the frontend dev server's own.
- `base44 exec` loads an SDK released in the last 24 hours instead of Deno's default minimum dependency age silently holding scripts on the previous one. Other npm packages a script imports keep the default.

### Added
Expand Down
8 changes: 6 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions docs/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ Read these when working on the relevant area:
- **[Adding or modifying CLI commands](commands.md)** - Factory pattern, `runCommand()`, `runTask()`, `CLIContext`, theming, `chalk` ban
- **[Making API calls](api-patterns.md)** - HTTP clients, Zod snake_case-to-camelCase transforms, `ApiError.fromHttpError()`
- **[Working with resources](resources.md)** - `Resource<T>` interface, adding new resources, site module, unified deploy
- **[Local dev server](dev-server.md)** - `base44 dev` as the front door, SDK header injection into app-owned code, app dev server discovery
- **[Deployments](deployments.md)** - Deploys addressed by commit, wrangler config, asset manifest hashing, direct asset uploads (Workers) and presigned uploads (static)
- **[Versions](versions.md)** - Recording a build as an immutable version and serving it, staged uploads with server-verified digests, raw resource payloads, the `BASE44_VERSIONS_API` gate
- **[Plugins](plugins.md)** - Plugin config, namespaces, entity extension rules, function namespacing, pull/deploy behavior
Expand Down
59 changes: 59 additions & 0 deletions docs/dev-server.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Local Dev Server

**Keywords:** base44 dev, dev server, front door, proxy, SSR, full-stack, server functions, createClientFromRequest, Base44-App-Id, Base44-Api-Url, service authorization, HMR, websocket, serveCommand, app dev server

`base44 dev` runs the app's backend locally and, when the project configures
`site.serveCommand`, the app's own dev server next to it. The CLI is the single
origin the user opens — the same shape the platform serves in production.

## The Front Door

One express server (`src/cli/dev/dev-server/main.ts`, port 4400 by default)
answers everything:

- `/api/apps/**` — the platform's own surface: entities, functions, auth, media,
integrations, falling back to a proxy to base44.app for what the local server
does not implement.
- everything else — forwarded to the app's dev server, **including the app's own
routes under `/api`** (`/api/time` is the app's; `/api/apps/...` is not).

Requests that reach app-owned code carry the SDK header contract, because
`createClientFromRequest` cannot build a client without it
(`src/cli/dev/dev-server/routes/app-server.ts`):

| Header | Value |
| --- | --- |
| `Base44-App-Id` | the linked app's id |
| `Base44-Api-Url` | the front door's own origin, so the app's SDK calls back into it |
| `Base44-Service-Authorization` | a locally minted service-role JWT, so `asServiceRole` works for anonymous visitors |

The visitor's own copies of those headers (and `Base44-State`, `X-Data-Env`,
`Base44-Functions-Version`, `X-Base44-App-Url`) are **dropped before ours are
set** — user code initializes its SDK from them, so a caller-supplied
`Base44-Api-Url` would point that SDK, and the credential travelling with it, at
a host the caller chose. `Authorization` passes through untouched: it is the
visitor the app's server code acts as. Same contract as the platform's
published-worker and sandbox-preview proxies.

Websocket upgrades follow the same split: the entity-events socket keeps
`REALTIME_PATH`, everything else (the app's HMR client) is forwarded.

## Finding the App Dev Server

The dev server picks its own port, so `ServeRunner` reads the origin off the
first `http://localhost:<port>` the child announces on startup, stripping ANSI
first (Vite bolds the port). The front door's own port is skipped — the Vite
plugin logs its API proxy target, and taking that for the app's address would
point the front door at itself. Requests that arrive before the announcement
wait for it.

A request that comes back carrying `Base44-Dev-Forwarded` means the app dev
server bounced it instead of serving it — almost always a Vite proxy forwarding
all of `/api` rather than just `/api/apps`. That answers 508 with the fix rather
than looping.

## Testing

`tests/cli/dev-app-server.spec.ts` drives the real command against the
`with-app-server` fixture, whose `serveCommand` is a node server that announces
a Vite-shaped URL and echoes what each request arrived with.
2 changes: 2 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"@types/ms": "^2.1.0",
"@types/multer": "^2.0.0",
"@types/node": "^22.10.5",
"@types/ws": "^8.18.1",
"@vercel/detect-agent": "^1.1.0",
"chalk": "^5.6.2",
"chokidar": "^5.0.0",
Expand Down Expand Up @@ -90,6 +91,7 @@
"tmp-promise": "^3.0.3",
"typescript": "^5.7.2",
"vitest": "^4.0.16",
"ws": "8.21.0",
"yaml": "^2.8.2",
"zod": "^4.3.5"
},
Expand Down
41 changes: 40 additions & 1 deletion packages/cli/src/cli/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,42 @@ type ConfiguredSite = Pick<
"serveCommand" | "projectRoot"
>;

const APP_SERVER_START_TIMEOUT_MS = 120_000;

function localServerUrl(port: number): string {
return `http://localhost:${port}`;
}

/**
* The app dev server picks its own port and announces it on startup, so the
* front door learns where to forward from the line it prints.
*/
function createAppServerOrigin(): {
waitForOrigin: () => Promise<string>;
report: (origin: string) => void;
} {
let report!: (origin: string) => void;
const announced = new Promise<string>((resolve) => {
report = resolve;
});
const timedOut = new Promise<never>((_, reject) => {
const timer = setTimeout(
() =>
reject(
new Error(
"The app dev server did not print a local URL, so requests cannot be forwarded to it.",
),
),
APP_SERVER_START_TIMEOUT_MS,
);
timer.unref();
});
return {
waitForOrigin: () => Promise.race([announced, timedOut]),
report,
};
}

function validateDevOptions(command: Command): void {
const { appId } = command.optsWithGlobals<AppIdOptions>();
if (appId !== undefined) {
Expand Down Expand Up @@ -113,11 +145,15 @@ async function localDevAction(
): Promise<RunCommandResult> {
const site = await resolveConfiguredSite(app);
const siteUrlPromise = getSiteUrl().catch(() => undefined);
const appServerOrigin = createAppServerOrigin();

const backend = await createDevServer({
log: ctx.log,
port: options.port ? Number(options.port) : undefined,
denoWrapperPath: getDenoWrapperPath(),
appServer: site
? { appId: app.id, waitForOrigin: appServerOrigin.waitForOrigin }
: undefined,
loadResources: async () => {
const { functions, entities, project } = await readProjectConfig();
const siteUrl = await siteUrlPromise;
Expand All @@ -131,12 +167,15 @@ async function localDevAction(
...site,
appId: app.id,
appBaseUrl: backendUrl,
onOrigin: appServerOrigin.report,
ignorePort: backend.port,
});
startServeCommand(runner, { url: backendUrl, shutdown: backend.shutdown });
}

// One origin for the app and its backend, the way the platform serves it.
const outroMessage = site
? "Open your app using the frontend dev server URL"
? `Your app is available at ${theme.colors.links(backendUrl)}`
: `Dev server is available at ${theme.colors.links(backendUrl)}`;

return { outroMessage };
Expand Down
28 changes: 28 additions & 0 deletions packages/cli/src/cli/dev/dev-server/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import { createProxyMiddleware } from "http-proxy-middleware";
import { dir } from "tmp-promise";
import { createDevLogger } from "@/cli/dev/createDevLogger.js";
import { createFunctionRuntime } from "@/cli/dev/dev-server/function-runtime.js";
import {
type AppServerTarget,
createAppServerProxy,
} from "@/cli/dev/dev-server/routes/app-server.js";
import { createFunctionRouter } from "@/cli/dev/dev-server/routes/functions.js";
import { theme } from "@/cli/utils/index.js";
import type { ProjectData } from "@/core/project/types.js";
Expand All @@ -16,6 +20,7 @@ import {
type BroadcastEntityEvent,
broadcastEntityEvent,
createRealtimeServer,
REALTIME_PATH,
} from "./realtime.js";
import { createAuthRouter } from "./routes/auth-router.js";
import { createEntityRoutes } from "./routes/entities/entities-router.js";
Expand All @@ -29,10 +34,16 @@ import { WatchBase44 } from "./watcher.js";
const DEFAULT_PORT = 4400;
const BASE44_APP_URL = "https://base44.app";

// Paths the platform owns. Everything else belongs to the app's own server —
// including its server routes, which frameworks conventionally put under /api.
const PLATFORM_PATH_PATTERN = /^\/api\/apps(\/|$)/;

interface DevServerOptions {
log: Logger;
port?: number;
denoWrapperPath: string;
/** The app's own dev server, fronted by this one when the project has a site. */
appServer?: AppServerTarget;
loadResources: () => Promise<{
functions: ProjectData["functions"];
entities: ProjectData["entities"];
Expand Down Expand Up @@ -164,7 +175,14 @@ export async function createDevServer(
);
app.use("/api/apps/:appId/integrations/custom", customIntegrationRoutes);

const appServerProxy = options.appServer
? createAppServerProxy(options.appServer, devLogger)
: undefined;

app.use((req, res, next) => {
if (appServerProxy && !PLATFORM_PATH_PATTERN.test(req.path)) {
return appServerProxy.middleware(req, res, next);
}
if (siteUrl && (req.path === "/login" || req.path.startsWith("/login/"))) {
const targetUrl = new URL(req.originalUrl, siteUrl);
devLogger.warn(
Expand Down Expand Up @@ -200,6 +218,16 @@ export async function createDevServer(
});
});

if (appServerProxy) {
// Every upgrade that is not the entity-events socket belongs to the app —
// its HMR client among them.
server.on("upgrade", (req, socket, head) => {
if (!req.url?.startsWith(REALTIME_PATH)) {
appServerProxy.upgrade(req, socket, head);
}
});
}

const io = createRealtimeServer(server);
emitEntityEvent = (appId, entityName, event) => {
broadcastEntityEvent(io, appId, entityName, event);
Expand Down
5 changes: 4 additions & 1 deletion packages/cli/src/cli/dev/dev-server/realtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,12 @@ export type BroadcastEntityEvent = (
event: EntityEvent,
) => void;

/** The path the entity-events socket owns; no other transport may claim it. */
export const REALTIME_PATH = "/ws-user-apps/socket.io/";

export function createRealtimeServer(httpServer: HttpServer): SocketIOServer {
const io = new SocketIOServer(httpServer, {
path: "/ws-user-apps/socket.io/",
path: REALTIME_PATH,
cors: {
origin: /^http:\/\/localhost(:\d+)?$/,
credentials: true,
Expand Down
Loading
Loading