From 761d7349c9ee904f30fa25d7421f2dc933b3a8a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 12:21:39 +0000 Subject: [PATCH 1/3] feat(dev): front the app's dev server with the SDK header contract `base44 dev` now serves the whole app on one origin. It answers `/api/apps` itself and forwards everything else, including the app's own `/api` routes and its HMR websocket, to the app dev server. Forwarded requests carry `Base44-App-Id`, `Base44-Api-Url` and a service-role credential, and the caller's copies of those headers are dropped. Full-stack apps can then build an SDK client with `createClientFromRequest` locally, as they do in production. Websockets are relayed at the frame level through `ws`, because the Bun compiled binary never writes bytes from a node:http upgrade socket to the wire. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Tmb4PCrrV2SLRpBjkGAgXJ --- CHANGELOG.md | 1 + bun.lock | 8 +- docs/AGENTS.md | 1 + docs/dev-server.md | 59 +++++ packages/cli/package.json | 2 + packages/cli/src/cli/commands/dev.ts | 41 +++- packages/cli/src/cli/dev/dev-server/main.ts | 28 +++ .../cli/src/cli/dev/dev-server/realtime.ts | 5 +- .../cli/dev/dev-server/routes/app-server.ts | 206 ++++++++++++++++++ .../src/cli/dev/dev-server/serve-runner.ts | 38 ++++ .../cli/src/cli/dev/serve-command-runner.ts | 6 + packages/cli/tests/cli/dev-app-server.spec.ts | 142 ++++++++++++ packages/cli/tests/cli/testkit/dev-utils.ts | 2 +- .../fixtures/with-app-server/app-server.cjs | 39 ++++ .../with-app-server/base44/config.jsonc | 6 + 15 files changed, 579 insertions(+), 5 deletions(-) create mode 100644 docs/dev-server.md create mode 100644 packages/cli/src/cli/dev/dev-server/routes/app-server.ts create mode 100644 packages/cli/tests/cli/dev-app-server.spec.ts create mode 100644 packages/cli/tests/fixtures/with-app-server/app-server.cjs create mode 100644 packages/cli/tests/fixtures/with-app-server/base44/config.jsonc diff --git a/CHANGELOG.md b/CHANGELOG.md index e203778bb..afba99b09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/bun.lock b/bun.lock index 2aba89195..d597ae790 100644 --- a/bun.lock +++ b/bun.lock @@ -11,7 +11,7 @@ }, "packages/cli": { "name": "base44", - "version": "0.1.25", + "version": "0.1.32", "bin": { "base44": "./bin/run.js", }, @@ -38,6 +38,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", @@ -72,13 +73,14 @@ "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", }, }, "packages/functions-compiler": { "name": "@base44/functions-compiler", - "version": "0.1.5", + "version": "0.1.6", "dependencies": { "@deno/loader": "npm:@jsr/deno__loader@0.5.0", "esbuild": "0.28.0", @@ -470,6 +472,8 @@ "@types/statuses": ["@types/statuses@2.0.6", "", {}, "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA=="], + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@vercel/detect-agent": ["@vercel/detect-agent@1.1.0", "", {}, "sha512-Zfq6FbIcYl9gaAmVu6ROsqUiCNwpEj3Ljz/tMX5fl12Z95OFOxzf7vlO03WE5JBU/ri1tBDFHnW41dihMINOPQ=="], "@vitest/expect": ["@vitest/expect@4.0.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ=="], diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 057b1269b..ec81219d2 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -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` 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 diff --git a/docs/dev-server.md b/docs/dev-server.md new file mode 100644 index 000000000..11480a27a --- /dev/null +++ b/docs/dev-server.md @@ -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:` 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. diff --git a/packages/cli/package.json b/packages/cli/package.json index ac0a82ccc..a8187ad99 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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", @@ -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" }, diff --git a/packages/cli/src/cli/commands/dev.ts b/packages/cli/src/cli/commands/dev.ts index 92614eae6..8dc204008 100644 --- a/packages/cli/src/cli/commands/dev.ts +++ b/packages/cli/src/cli/commands/dev.ts @@ -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; + report: (origin: string) => void; +} { + let report!: (origin: string) => void; + const announced = new Promise((resolve) => { + report = resolve; + }); + const timedOut = new Promise((_, 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(); if (appId !== undefined) { @@ -113,11 +145,15 @@ async function localDevAction( ): Promise { 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; @@ -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 }; diff --git a/packages/cli/src/cli/dev/dev-server/main.ts b/packages/cli/src/cli/dev/dev-server/main.ts index d067f97e8..bc545463f 100644 --- a/packages/cli/src/cli/dev/dev-server/main.ts +++ b/packages/cli/src/cli/dev/dev-server/main.ts @@ -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"; @@ -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"; @@ -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"]; @@ -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( @@ -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); diff --git a/packages/cli/src/cli/dev/dev-server/realtime.ts b/packages/cli/src/cli/dev/dev-server/realtime.ts index bdac48d38..e5338ee8f 100644 --- a/packages/cli/src/cli/dev/dev-server/realtime.ts +++ b/packages/cli/src/cli/dev/dev-server/realtime.ts @@ -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, diff --git a/packages/cli/src/cli/dev/dev-server/routes/app-server.ts b/packages/cli/src/cli/dev/dev-server/routes/app-server.ts new file mode 100644 index 000000000..cb2e6b0b3 --- /dev/null +++ b/packages/cli/src/cli/dev/dev-server/routes/app-server.ts @@ -0,0 +1,206 @@ +import type { IncomingMessage } from "node:http"; +import { ServerResponse } from "node:http"; +import type { Duplex } from "node:stream"; +import type { RequestHandler } from "express"; +import { createProxyMiddleware } from "http-proxy-middleware"; +import { type RawData, WebSocket, WebSocketServer } from "ws"; +import type { DevLogger } from "@/cli/dev/createDevLogger.js"; +import { createServiceAuthorizationHeader } from "@/cli/dev/dev-server/auth/tokens.js"; + +/** + * The request headers the SDK's `createClientFromRequest` reads. Any transport + * that reverse-proxies a visitor request into app-owned code has to present + * them, or the app's server code cannot build a client at all — and has to drop + * the visitor's own copies first, since user code initializes its SDK from + * them and a caller-supplied `Base44-Api-Url` would point that SDK, and the + * credential travelling with it, at a host the caller chose. Same contract as + * the platform's published-worker and sandbox-preview proxies. + */ +const PLATFORM_OWNED_SDK_HEADERS = [ + "base44-app-id", + "base44-api-url", + "base44-service-authorization", + "base44-state", + "base44-functions-version", + "x-base44-app-url", + "x-data-env", +]; + +/** + * Set on every request we forward. Seeing it come back in means the app dev + * server bounced the request at us instead of serving it. + */ +const FORWARDED_HEADER = "base44-dev-forwarded"; + +const PROXY_LOOP_MESSAGE = [ + "The app dev server sent this request back to the Base44 dev server instead of handling it.", + "Its Vite proxy is forwarding all of /api; only /api/apps belongs to the backend.", + "Update @base44/vite-plugin, or narrow the proxy in vite.config.js to /api/apps.", +].join(" "); + +/** What a request into app code carries, in place of any copies of its own. */ +function sdkHeaders( + appId: string, + host: string | undefined, +): Record { + return { + "Base44-App-Id": appId, + // The front door is plain HTTP on localhost, so its origin is the host the + // request reached. + "Base44-Api-Url": `http://${host}`, + // Production injects a service-role token on every request into app code, + // so `asServiceRole` works server-side for anonymous visitors too. + "Base44-Service-Authorization": createServiceAuthorizationHeader(), + [FORWARDED_HEADER]: "1", + }; +} + +export interface AppServerTarget { + appId: string; + /** Resolves with the app dev server's origin once it announces itself. */ + waitForOrigin: () => Promise; +} + +interface AppServerProxy { + middleware: RequestHandler; + upgrade: (req: IncomingMessage, socket: Duplex, head: Buffer) => void; +} + +/** + * Fronts the app's own dev server the way the platform fronts a published app: + * the CLI is the single origin, `/api/apps` is answered locally, and everything + * else reaches the app's server carrying the SDK header contract. + */ +export function createAppServerProxy( + target: AppServerTarget, + logger: DevLogger, +): AppServerProxy { + // Resolved before any request is handed to the proxy; the placeholder only + // satisfies the option's type until the dev server announces its port. + let origin = "http://127.0.0.1:0"; + + const proxy = createProxyMiddleware({ + router: () => origin, + changeOrigin: true, + on: { + proxyReq: (proxyReq, req) => { + for (const header of PLATFORM_OWNED_SDK_HEADERS) { + proxyReq.removeHeader(header); + } + for (const [name, value] of Object.entries( + sdkHeaders(target.appId, req.headers.host), + )) { + proxyReq.setHeader(name, value); + } + }, + error: (err, _req, res) => { + logger.error("App dev server proxy error:", err); + if (res instanceof ServerResponse && !res.headersSent) { + res.writeHead(502, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + error: "Failed to proxy request to the app dev server", + details: err.message, + }), + ); + } + }, + }, + }); + + const resolveOrigin = async (): Promise => { + origin = await target.waitForOrigin(); + }; + + const middleware: RequestHandler = async (req, res, next) => { + if (req.headers[FORWARDED_HEADER]) { + logger.error(PROXY_LOOP_MESSAGE); + res.status(508).json({ error: PROXY_LOOP_MESSAGE }); + return; + } + try { + await resolveOrigin(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error(message); + res.status(502).json({ error: message }); + return; + } + await proxy(req, res, next); + }; + + // Relayed message by message rather than tunnelled: under Bun, which runs the + // compiled binary, bytes written to the socket an `upgrade` listener receives + // never reach the wire. `ws` is the one path Bun maps onto its own websockets. + const relay = new WebSocketServer({ + noServer: true, + handleProtocols: (protocols) => [...protocols][0] ?? false, + }); + + const upgrade = (req: IncomingMessage, socket: Duplex, head: Buffer) => { + resolveOrigin().then( + () => + relay.handleUpgrade(req, socket, head, (client) => + relayTo(client, req), + ), + (error: unknown) => { + logger.error(error instanceof Error ? error.message : String(error)); + socket.destroy(); + }, + ); + }; + + const relayTo = (client: WebSocket, req: IncomingMessage) => { + const forwarded: Record = {}; + for (let i = 0; i < req.rawHeaders.length; i += 2) { + const lower = req.rawHeaders[i].toLowerCase(); + // The handshake's own headers are the upstream client's to set. + if ( + lower === "host" || + lower.startsWith("sec-websocket-") || + lower === "upgrade" || + lower === "connection" || + PLATFORM_OWNED_SDK_HEADERS.includes(lower) + ) { + continue; + } + forwarded[req.rawHeaders[i]] = req.rawHeaders[i + 1]; + } + const upstream = new WebSocket( + `${origin.replace(/^http/, "ws")}${req.url}`, + client.protocol || undefined, + { + headers: { + ...forwarded, + ...sdkHeaders(target.appId, req.headers.host), + }, + }, + ); + + // The browser may speak before the dev server has answered. + const pending: Array<{ data: RawData; isBinary: boolean }> = []; + client.on("message", (data, isBinary) => { + if (upstream.readyState === WebSocket.OPEN) { + upstream.send(data, { binary: isBinary }); + } else { + pending.push({ data, isBinary }); + } + }); + upstream.on("open", () => { + for (const { data, isBinary } of pending.splice(0)) { + upstream.send(data, { binary: isBinary }); + } + }); + upstream.on("message", (data, isBinary) => + client.send(data, { binary: isBinary }), + ); + client.on("close", () => upstream.close()); + upstream.on("close", () => client.close()); + upstream.on("error", (error) => { + logger.error("App dev server websocket error:", error); + client.close(); + }); + }; + + return { middleware, upgrade }; +} diff --git a/packages/cli/src/cli/dev/dev-server/serve-runner.ts b/packages/cli/src/cli/dev/dev-server/serve-runner.ts index 515634c71..68e2628c3 100644 --- a/packages/cli/src/cli/dev/dev-server/serve-runner.ts +++ b/packages/cli/src/cli/dev/dev-server/serve-runner.ts @@ -8,13 +8,31 @@ interface ServeRunnerOptions { cwd: string; env: Record; logger: DevLogger; + /** Called once with the local origin the dev server announced on startup. */ + onOrigin?: (origin: string) => void; + /** + * Port to disregard while looking for that origin: the dev server echoes the + * backend URL we handed it (the Vite plugin logs its API proxy target), and + * taking that for its own address would point the front door at itself. + */ + ignorePort?: number; } +// Dev servers announce themselves with a line like "Local: http://localhost:5173/". +// Vite bolds the port, so the escape codes have to go before the match. +// Built at runtime: an escape character is not allowed in a regex literal. +const ANSI_PATTERN = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g"); +const LOCAL_ORIGIN_PATTERN = + /https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\]):(\d+)/g; + export class ServeRunner { private readonly command: string; private readonly cwd: string; private readonly env: Record; private readonly logger: DevLogger; + private readonly onOrigin?: (origin: string) => void; + private readonly ignorePort?: number; + private originReported = false; private child?: ChildProcess; private stopping = false; private stopPromise?: Promise; @@ -25,6 +43,8 @@ export class ServeRunner { this.cwd = options.cwd; this.env = options.env; this.logger = options.logger; + this.onOrigin = options.onOrigin; + this.ignorePort = options.ignorePort; } start(): void { @@ -117,6 +137,7 @@ export class ServeRunner { private emitLines(data: Buffer, type: "log" | "error"): void { const lines = data.toString().trimEnd().split("\n"); for (const line of lines) { + this.reportOrigin(line); if (type === "error") { this.logger.error(line); } else { @@ -124,4 +145,21 @@ export class ServeRunner { } } } + + private reportOrigin(line: string): void { + if (this.originReported || !this.onOrigin) { + return; + } + const matches = line + .replace(ANSI_PATTERN, "") + .matchAll(LOCAL_ORIGIN_PATTERN); + for (const [origin, port] of matches) { + if (Number(port) === this.ignorePort) { + continue; + } + this.originReported = true; + this.onOrigin(origin); + return; + } + } } diff --git a/packages/cli/src/cli/dev/serve-command-runner.ts b/packages/cli/src/cli/dev/serve-command-runner.ts index 7a8603d44..c2cb074d9 100644 --- a/packages/cli/src/cli/dev/serve-command-runner.ts +++ b/packages/cli/src/cli/dev/serve-command-runner.ts @@ -10,6 +10,8 @@ export interface ServeCommandRunnerOptions { * backend same-origin (the Base44 vite plugin proxies `/api`) must not be told * one, or the SDK would call across origins instead. */ appBaseUrl?: string; + onOrigin?: (origin: string) => void; + ignorePort?: number; } export function createServeCommandRunner({ @@ -17,6 +19,8 @@ export function createServeCommandRunner({ projectRoot, appId, appBaseUrl, + onOrigin, + ignorePort, }: ServeCommandRunnerOptions): ServeRunner { return new ServeRunner({ command: serveCommand, @@ -26,5 +30,7 @@ export function createServeCommandRunner({ ...(appBaseUrl ? { VITE_BASE44_APP_BASE_URL: appBaseUrl } : {}), }, logger: createDevLogger("frontend", theme.colors.base44Orange), + onOrigin, + ignorePort, }); } diff --git a/packages/cli/tests/cli/dev-app-server.spec.ts b/packages/cli/tests/cli/dev-app-server.spec.ts new file mode 100644 index 000000000..96ba57192 --- /dev/null +++ b/packages/cli/tests/cli/dev-app-server.spec.ts @@ -0,0 +1,142 @@ +import jwt from "jsonwebtoken"; +import { describe, expect, it } from "vitest"; +import { SERVICE_ROLE_EMAIL } from "@/cli/dev/dev-server/auth/tokens.js"; +import { waitForDevServer } from "./testkit/dev-utils.js"; +import { fixture, setupCLITests } from "./testkit/index.js"; + +interface EchoedRequest { + url: string; + headers: Record; +} + +describe("dev command fronting the app dev server", () => { + const t = setupCLITests(); + + const startDevServer = async () => { + await t.givenLoggedInWithProject(fixture("with-app-server")); + const handle = await t.runLive("dev"); + const url = await waitForDevServer(handle); + return { handle, url }; + }; + + it("forwards requests the platform does not own, carrying the SDK headers", async () => { + const { handle, url } = await startDevServer(); + + const response = await fetch(`${url}/`, { + headers: { Authorization: "Bearer visitor-token" }, + }); + + expect(response.status).toBe(200); + const { url: forwardedUrl, headers } = + (await response.json()) as EchoedRequest; + expect(forwardedUrl).toBe("/"); + expect(headers["base44-app-id"]).toBe(t.api.appId); + expect(headers["base44-api-url"]).toBe(url); + // The visitor's own token is what the app's server code acts as. + expect(headers.authorization).toBe("Bearer visitor-token"); + const serviceToken = headers["base44-service-authorization"]?.replace( + "Bearer ", + "", + ); + expect(jwt.decode(serviceToken ?? "")?.sub).toBe(SERVICE_ROLE_EMAIL); + + const result = await handle.stop(); + t.expectResult(result).toSucceed(); + }); + + it("drops the visitor's copies of the platform-owned headers", async () => { + const { handle, url } = await startDevServer(); + + const response = await fetch(`${url}/`, { + headers: { + "Base44-App-Id": "spoofed-app", + "Base44-Api-Url": "http://attacker.example", + "Base44-Service-Authorization": "Bearer spoofed", + "X-Data-Env": "prod", + }, + }); + + const { headers } = (await response.json()) as EchoedRequest; + expect(headers["base44-app-id"]).toBe(t.api.appId); + expect(headers["base44-api-url"]).toBe(url); + expect(headers["base44-service-authorization"]).not.toBe("Bearer spoofed"); + expect(headers["x-data-env"]).toBeUndefined(); + + const result = await handle.stop(); + t.expectResult(result).toSucceed(); + }); + + it("forwards the app's own routes under /api", async () => { + const { handle, url } = await startDevServer(); + + const response = await fetch(`${url}/api/time`); + + const { url: forwardedUrl } = (await response.json()) as EchoedRequest; + expect(forwardedUrl).toBe("/api/time"); + + const result = await handle.stop(); + t.expectResult(result).toSucceed(); + }); + + it("relays the app's HMR socket to its dev server, carrying the SDK headers", async () => { + const { handle, url } = await startDevServer(); + + const socket = new WebSocket(`${url.replace("http", "ws")}/__hmr`); + const first = await new Promise((resolve, reject) => { + socket.addEventListener("message", (event) => + resolve(String(event.data)), + ); + socket.addEventListener("error", () => + reject(new Error("The upgrade never reached the app dev server")), + ); + }); + socket.close(); + + // The dev server's own frame, so the socket reached it rather than only + // being accepted at the front door. + const { url: forwardedUrl, headers } = JSON.parse(first) as EchoedRequest; + expect(forwardedUrl).toBe("/__hmr"); + expect(headers["base44-app-id"]).toBe(t.api.appId); + + const result = await handle.stop(); + t.expectResult(result).toSucceed(); + }); + + it("leaves the entity-events socket to the local backend", async () => { + const { handle, url } = await startDevServer(); + + const socket = new WebSocket( + `${url.replace("http", "ws")}/ws-user-apps/socket.io/?EIO=4&transport=websocket`, + ); + const firstPacket = await new Promise((resolve, reject) => { + socket.addEventListener("message", (event) => + resolve(String(event.data)), + ); + socket.addEventListener("error", () => + reject(new Error("The realtime socket did not connect")), + ); + }); + socket.close(); + + // Engine.IO's own open packet — the app dev server never saw this upgrade. + expect(firstPacket.startsWith("0{")).toBe(true); + + const result = await handle.stop(); + t.expectResult(result).toSucceed(); + }); + + it("keeps /api/apps for the local backend", async () => { + const { handle, url } = await startDevServer(); + + const response = await fetch( + `${url}/api/apps/${t.api.appId}/functions/missing`, + { headers: { "X-App-Id": t.api.appId } }, + ); + + const body = (await response.json()) as Partial; + expect(body.headers).toBeUndefined(); + + const result = await handle.stop(); + t.expectResult(result).toSucceed(); + }); +}); diff --git a/packages/cli/tests/cli/testkit/dev-utils.ts b/packages/cli/tests/cli/testkit/dev-utils.ts index 16766b679..6f68ebdc3 100644 --- a/packages/cli/tests/cli/testkit/dev-utils.ts +++ b/packages/cli/tests/cli/testkit/dev-utils.ts @@ -3,7 +3,7 @@ import type { RunLiveHandle } from "./CLITestkit"; export const waitForDevServer = async ( runLiveHandle: RunLiveHandle, ): Promise => { - const pattern = /Dev server is available at (http\S+)/; + const pattern = /(?:Dev server|Your app) is available at (http\S+)/; await runLiveHandle.waitForOutput(pattern); const match = runLiveHandle.stdout.join("").match(pattern); if (!match) { diff --git a/packages/cli/tests/fixtures/with-app-server/app-server.cjs b/packages/cli/tests/fixtures/with-app-server/app-server.cjs new file mode 100644 index 000000000..cb5d2fbca --- /dev/null +++ b/packages/cli/tests/fixtures/with-app-server/app-server.cjs @@ -0,0 +1,39 @@ +// Stands in for a framework dev server: announces its URL the way Vite does +// (with the port bolded), then echoes what each request arrived with. +const { createHash } = require("node:crypto"); +const { createServer } = require("node:http"); + +const server = createServer((req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ url: req.url, headers: req.headers })); +}); + +// Stands in for the framework's HMR socket: completes the handshake, then sends +// one text frame naming what the upgrade arrived with. +server.on("upgrade", (req, socket) => { + // A real dev server survives a peer resetting the socket; so must this one. + socket.on("error", () => {}); + const accept = createHash("sha1") + .update(req.headers["sec-websocket-key"] + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11") + .digest("base64"); + socket.write( + "HTTP/1.1 101 Switching Protocols\r\n" + + "Upgrade: websocket\r\n" + + "Connection: Upgrade\r\n" + + `Sec-WebSocket-Accept: ${accept}\r\n\r\n`, + ); + const payload = Buffer.from( + JSON.stringify({ url: req.url, headers: req.headers }), + ); + // An unmasked server text frame; payloads here stay under 64 KiB. + const header = + payload.length < 126 + ? Buffer.from([0x81, payload.length]) + : Buffer.from([0x81, 126, payload.length >> 8, payload.length & 0xff]); + socket.write(Buffer.concat([header, payload])); +}); + +server.listen(0, "127.0.0.1", () => { + const { port } = server.address(); + console.log(` \u001b[32m➜\u001b[39m Local: \u001b[36mhttp://localhost:\u001b[1m${port}\u001b[22m/\u001b[39m`); +}); diff --git a/packages/cli/tests/fixtures/with-app-server/base44/config.jsonc b/packages/cli/tests/fixtures/with-app-server/base44/config.jsonc new file mode 100644 index 000000000..d43f32ad1 --- /dev/null +++ b/packages/cli/tests/fixtures/with-app-server/base44/config.jsonc @@ -0,0 +1,6 @@ +{ + "name": "App Server Project", + "site": { + "serveCommand": "node app-server.cjs" + } +} From 3dd96ab96ab48426bd721cd97682643231d23bc0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 15:30:21 +0000 Subject: [PATCH 2/3] fix(dev): keep /api/app-logs on the platform side of the front door The SDK's appLogs module calls /api/app-logs/{appId}/..., which is the platform's, not the app's. The front door only kept /api/apps local, so those calls went to the app's dev server instead of the backend. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Tmb4PCrrV2SLRpBjkGAgXJ --- CHANGELOG.md | 2 +- docs/dev-server.md | 6 +++--- packages/cli/src/cli/dev/dev-server/main.ts | 2 +- .../src/cli/dev/dev-server/routes/app-server.ts | 4 ++-- packages/cli/tests/cli/dev-app-server.spec.ts | 15 +++++++++++++++ 5 files changed, 22 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e8c2f44b..1c1991613 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +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 dev` now fronts the app's own dev server: it serves the whole app on one origin, answers `/api/apps` and `/api/app-logs` 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 diff --git a/docs/dev-server.md b/docs/dev-server.md index 11480a27a..e3259f0b9 100644 --- a/docs/dev-server.md +++ b/docs/dev-server.md @@ -11,8 +11,8 @@ origin the user opens — the same shape the platform serves in production. 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 +- `/api/apps/**` and `/api/app-logs/**` — the platform's own surface: entities, + functions, auth, media, integrations, app logs, 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). @@ -49,7 +49,7 @@ 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 +all of `/api` rather than just `/api/apps` and `/api/app-logs`. That answers 508 with the fix rather than looping. ## Testing diff --git a/packages/cli/src/cli/dev/dev-server/main.ts b/packages/cli/src/cli/dev/dev-server/main.ts index bc545463f..0622913a6 100644 --- a/packages/cli/src/cli/dev/dev-server/main.ts +++ b/packages/cli/src/cli/dev/dev-server/main.ts @@ -36,7 +36,7 @@ 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(\/|$)/; +const PLATFORM_PATH_PATTERN = /^\/api\/(apps|app-logs)(\/|$)/; interface DevServerOptions { log: Logger; diff --git a/packages/cli/src/cli/dev/dev-server/routes/app-server.ts b/packages/cli/src/cli/dev/dev-server/routes/app-server.ts index cb2e6b0b3..a216c0aaf 100644 --- a/packages/cli/src/cli/dev/dev-server/routes/app-server.ts +++ b/packages/cli/src/cli/dev/dev-server/routes/app-server.ts @@ -34,8 +34,8 @@ const FORWARDED_HEADER = "base44-dev-forwarded"; const PROXY_LOOP_MESSAGE = [ "The app dev server sent this request back to the Base44 dev server instead of handling it.", - "Its Vite proxy is forwarding all of /api; only /api/apps belongs to the backend.", - "Update @base44/vite-plugin, or narrow the proxy in vite.config.js to /api/apps.", + "Its Vite proxy is forwarding all of /api; only /api/apps and /api/app-logs belong to the backend.", + "Update @base44/vite-plugin, or narrow the proxy in vite.config.js to those paths.", ].join(" "); /** What a request into app code carries, in place of any copies of its own. */ diff --git a/packages/cli/tests/cli/dev-app-server.spec.ts b/packages/cli/tests/cli/dev-app-server.spec.ts index 96ba57192..9ba61c340 100644 --- a/packages/cli/tests/cli/dev-app-server.spec.ts +++ b/packages/cli/tests/cli/dev-app-server.spec.ts @@ -139,4 +139,19 @@ describe("dev command fronting the app dev server", () => { const result = await handle.stop(); t.expectResult(result).toSucceed(); }); + + it("keeps the SDK's /api/app-logs off the app dev server", async () => { + const { handle, url } = await startDevServer(); + + const response = await fetch( + `${url}/api/app-logs/${t.api.appId}/log-user-in-app/Home`, + { method: "POST" }, + ); + + // The app dev server would echo the request back. + expect(await response.text()).not.toContain("log-user-in-app"); + + const result = await handle.stop(); + t.expectResult(result).toSucceed(); + }); }); From 0f02923ba006f5d7a9de0c8ea9517275a6fffe60 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 16:37:14 +0000 Subject: [PATCH 3/3] feat(dev): sign in to private apps through the front door, locally and --remote A private or workspace app is behind the platform's login in production. Under base44 dev, page loads without a session now go to the CLI's own /login, which signs the developer in as the CLI's account, hands the app an access token the way the hosted login does (?access_token=), and keeps it in a session cookie that also identifies the visitor on server-rendered page loads. The SDK's logout clears it. Public apps keep handling /login themselves. base44 dev --remote runs the same front door, with the platform's paths proxied to the live app, so a private app works against live data with the account's app-user token. It now takes --port, and Ctrl-C exits it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Tmb4PCrrV2SLRpBjkGAgXJ --- CHANGELOG.md | 2 + docs/dev-server.md | 41 ++++- packages/cli/src/cli/commands/dev.ts | 103 ++++++++--- packages/cli/src/cli/dev/dev-server/main.ts | 19 +- .../src/cli/dev/dev-server/platform-paths.ts | 3 + .../cli/dev/dev-server/remote-front-door.ts | 83 +++++++++ .../cli/dev/dev-server/routes/app-server.ts | 17 +- .../cli/dev/dev-server/routes/login-gate.ts | 164 ++++++++++++++++++ packages/cli/src/core/project/api.ts | 28 +++ packages/cli/src/core/project/schema.ts | 4 + packages/cli/tests/cli/dev-app-server.spec.ts | 99 +++++++++++ packages/cli/tests/cli/dev.spec.ts | 21 ++- .../with-private-app-server/app-server.cjs | 39 +++++ .../base44/config.jsonc | 7 + 14 files changed, 588 insertions(+), 42 deletions(-) create mode 100644 packages/cli/src/cli/dev/dev-server/platform-paths.ts create mode 100644 packages/cli/src/cli/dev/dev-server/remote-front-door.ts create mode 100644 packages/cli/src/cli/dev/dev-server/routes/login-gate.ts create mode 100644 packages/cli/tests/fixtures/with-private-app-server/app-server.cjs create mode 100644 packages/cli/tests/fixtures/with-private-app-server/base44/config.jsonc diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c1991613..df3376cc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Changed - `base44 dev` now fronts the app's own dev server: it serves the whole app on one origin, answers `/api/apps` and `/api/app-logs` 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 dev` signs you in to a private or workspace app the way the platform does: page loads without a session go to the CLI's own `/login`, which signs you in as the account the CLI is logged in with, hands the app its access token and keeps the session in a cookie, so pages the app renders on the server act as you too. A public app keeps handling `/login` itself. +- `base44 dev --remote` now runs the same front door, answering the platform's paths from your live app, so a private app gets the same sign-in, with a token for your live app's data. It also takes `--port`. - `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 diff --git a/docs/dev-server.md b/docs/dev-server.md index e3259f0b9..70c0d715f 100644 --- a/docs/dev-server.md +++ b/docs/dev-server.md @@ -1,6 +1,6 @@ # 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 +**Keywords:** base44 dev, dev --remote, dev server, front door, private app, login gate, /login, session cookie, access_token, 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 @@ -38,6 +38,41 @@ 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. +## `--remote` + +`base44 dev --remote` puts the same front door in front of the app dev server +(`src/cli/dev/dev-server/remote-front-door.ts`), but with no local backend: the +platform's paths, and the entity-events socket, are proxied to the live app +(its published URL). The app dev server still gets `Base44-App-Id` and +`Base44-Api-Url`, but no service-role token — the CLI cannot mint one the live +app accepts. Ctrl-C exits the process: the front door lives in it. + +## Private and Workspace Apps + +A public app handles sign-in itself, so `/login` goes to its dev server like any +other page. A private or workspace app is behind the platform's login in +production, and the CLI stands in for it locally +(`src/cli/dev/dev-server/routes/login-gate.ts`). Whether the app is gated comes +from `visibility` in `config.jsonc` (what deploy applies), else the app's +`public_settings` on the server; an unreachable server means public. + +- A page load (`GET`, `Accept: text/html`, outside `/api/` and `/media/`) with no + session cookie is redirected to `/login?from_url=…`. +- `/login` is the CLI's own page: "Continue as ". Submitting it + mints an access token, sets it as the `base44_dev_session` cookie (HttpOnly, + SameSite=Lax) and redirects to `from_url` with `?access_token=`, which the SDK + stores — the hosted login's own hand-off. `from_url` must be on the front + door's origin. +- A request with that cookie and no `Authorization` header gets the cookie's + token as one, so pages the app renders on the server act as the visitor, as + the platform's cookie makes them in production. +- The SDK's logout (`/api/apps/auth/logout?from_url=…`) clears the cookie. + +The token is the CLI account's identity for that data: locally a JWT for the +account seeded as admin in the local database (`createJwtToken`); under +`--remote`, the account's app-user token from the platform (`auth/token`), so +the app reads and writes the live app's data as that user. + ## Finding the App Dev Server The dev server picks its own port, so `ServeRunner` reads the origin off the @@ -56,4 +91,6 @@ than looping. `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. +a Vite-shaped URL and echoes what each request arrived with, and +`with-private-app-server`, the same app with `"visibility": "private"`, for the +login gate. diff --git a/packages/cli/src/cli/commands/dev.ts b/packages/cli/src/cli/commands/dev.ts index 8dc204008..89cfa923b 100644 --- a/packages/cli/src/cli/commands/dev.ts +++ b/packages/cli/src/cli/commands/dev.ts @@ -1,6 +1,9 @@ import type { Command } from "commander"; import { createDevLogger } from "@/cli/dev/createDevLogger.js"; +import { createJwtToken } from "@/cli/dev/dev-server/auth/tokens.js"; import { createDevServer } from "@/cli/dev/dev-server/main.js"; +import { createRemoteFrontDoor } from "@/cli/dev/dev-server/remote-front-door.js"; +import type { LoginGateOptions } from "@/cli/dev/dev-server/routes/login-gate.js"; import type { ServeRunner } from "@/cli/dev/dev-server/serve-runner.js"; import { createServeCommandRunner, @@ -10,10 +13,16 @@ import { stopRunnerOnProcessSignals } from "@/cli/dev/stop-runner-on-signals.js" import type { CLIContext, RunCommandResult } from "@/cli/types.js"; import { type AppIdOptions, Base44Command, theme } from "@/cli/utils/index.js"; import { getDenoWrapperPath } from "@/core/assets.js"; +import { readAuth } from "@/core/auth/config.js"; import { BASE44_APP_ID_ENV_VAR } from "@/core/consts.js"; import { ConfigInvalidError } from "@/core/errors.js"; -import { getSiteUrl } from "@/core/project/api.js"; +import { + getAppUserToken, + getAppVisibility, + getSiteUrl, +} from "@/core/project/api.js"; import { readProjectConfig } from "@/core/project/config.js"; +import type { Visibility } from "@/core/project/schema.js"; interface DevOptions { port?: string; @@ -73,12 +82,6 @@ function validateDevOptions(command: Command): void { `base44 dev cannot be used with --app-id or ${BASE44_APP_ID_ENV_VAR}.`, ); } - const { port, remote } = command.opts(); - if (remote && port !== undefined) { - command.error( - "--port applies to the local backend, which --remote does not start.", - ); - } } function requireLinkedProject({ app }: CLIContext): LinkedApp { @@ -90,12 +93,43 @@ function requireLinkedProject({ app }: CLIContext): LinkedApp { return { id: app.id, projectRoot: app.projectRoot }; } -async function resolveConfiguredSite( - app: LinkedApp, -): Promise { +async function resolveConfiguredSite(app: LinkedApp): Promise<{ + site?: ConfiguredSite; + visibility?: Visibility; +}> { const { project } = await readProjectConfig(app.projectRoot); const serveCommand = project.site?.serveCommand; - return serveCommand ? { serveCommand, projectRoot: project.root } : undefined; + return { + site: serveCommand + ? { serveCommand, projectRoot: project.root } + : undefined, + visibility: project.visibility, + }; +} + +/** + * A private or workspace app sits behind a login, which the CLI provides + * locally. The config's `visibility` is what deploy applies, so it wins; the + * server's setting decides otherwise, and an unreachable server means public. + */ +async function resolveLoginGate( + app: LinkedApp, + visibility: Visibility | undefined, + gate: Pick & { + mintToken: (email: string) => Promise; + }, +): Promise { + const resolved = + visibility ?? (await getAppVisibility(app.id).catch(() => "public")); + if (resolved === "public") { + return undefined; + } + const { email } = await readAuth(); + return { + email, + dataSource: gate.dataSource, + mintToken: () => gate.mintToken(email), + }; } function startServeCommand( @@ -115,26 +149,48 @@ function startServeCommand( runner.start(); } -async function remoteDevAction(app: LinkedApp): Promise { - const site = await resolveConfiguredSite(app); +async function remoteDevAction( + app: LinkedApp, + options: DevOptions, +): Promise { + const { site, visibility } = await resolveConfiguredSite(app); if (!site) { throw new ConfigInvalidError( "base44 dev --remote serves the frontend against the production backend, but this project has no site.serveCommand in base44/config.jsonc.", ); } - const appBaseUrl = await getSiteUrl(); + const siteUrl = await getSiteUrl(); + const loginGate = await resolveLoginGate(app, visibility, { + dataSource: `your live app's data (${siteUrl})`, + mintToken: () => getAppUserToken(), + }); + const appServerOrigin = createAppServerOrigin(); + const frontDoor = await createRemoteFrontDoor({ + port: options.port ? Number(options.port) : undefined, + siteUrl, + appServer: { appId: app.id, waitForOrigin: appServerOrigin.waitForOrigin }, + loginGate, + }); + const frontDoorUrl = localServerUrl(frontDoor.port); + const runner = createServeCommandRunner({ ...site, appId: app.id, - appBaseUrl, + appBaseUrl: frontDoorUrl, + onOrigin: appServerOrigin.report, + ignorePort: frontDoor.port, }); - stopRunnerOnProcessSignals(runner); + // The front door lives in this process, relayed sockets and all, so exiting + // is its shutdown — on Ctrl-C as much as when the app dev server dies. + const stopAndExit = () => void runner.stop().finally(() => process.exit(0)); + process.on("SIGINT", stopAndExit); + process.on("SIGTERM", stopAndExit); runner.onExit((code) => process.exit(code ?? 1)); runner.start(); return { - outroMessage: `Frontend dev server targets ${theme.styles.bold(appBaseUrl)} — every write hits your live app`, + outroMessage: `Your app is available at ${theme.colors.links(frontDoorUrl)} — it runs on the data of ${theme.styles.bold(siteUrl)}, so every write hits your live app`, }; } @@ -143,8 +199,14 @@ async function localDevAction( app: LinkedApp, options: DevOptions, ): Promise { - const site = await resolveConfiguredSite(app); + const { site, visibility } = await resolveConfiguredSite(app); const siteUrlPromise = getSiteUrl().catch(() => undefined); + const loginGate = site + ? await resolveLoginGate(app, visibility, { + dataSource: "a local database where your account is an admin", + mintToken: async (email) => createJwtToken(email), + }) + : undefined; const appServerOrigin = createAppServerOrigin(); const backend = await createDevServer({ @@ -154,6 +216,7 @@ async function localDevAction( appServer: site ? { appId: app.id, waitForOrigin: appServerOrigin.waitForOrigin } : undefined, + loginGate, loadResources: async () => { const { functions, entities, project } = await readProjectConfig(); const siteUrl = await siteUrlPromise; @@ -187,7 +250,7 @@ async function devAction( ): Promise { const app = requireLinkedProject(ctx); return options.remote - ? remoteDevAction(app) + ? remoteDevAction(app, options) : localDevAction(ctx, app, options); } @@ -197,7 +260,7 @@ export function getDevCommand(): Command { .option("-p, --port ", "Port for the development server") .option( "--remote", - "Serve the frontend against the production backend instead of a local one", + "Serve the app against your live app's data instead of a local backend", ) .hook("preAction", validateDevOptions) .action(devAction); diff --git a/packages/cli/src/cli/dev/dev-server/main.ts b/packages/cli/src/cli/dev/dev-server/main.ts index 0622913a6..432edbc02 100644 --- a/packages/cli/src/cli/dev/dev-server/main.ts +++ b/packages/cli/src/cli/dev/dev-server/main.ts @@ -13,9 +13,14 @@ import { createAppServerProxy, } from "@/cli/dev/dev-server/routes/app-server.js"; import { createFunctionRouter } from "@/cli/dev/dev-server/routes/functions.js"; +import { + createLoginGate, + type LoginGateOptions, +} from "@/cli/dev/dev-server/routes/login-gate.js"; import { theme } from "@/cli/utils/index.js"; import type { ProjectData } from "@/core/project/types.js"; import { Database } from "./db/database.js"; +import { PLATFORM_PATH_PATTERN } from "./platform-paths.js"; import { type BroadcastEntityEvent, broadcastEntityEvent, @@ -34,16 +39,14 @@ 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|app-logs)(\/|$)/; - 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; + /** Set for a private or workspace app: the CLI signs the developer in. */ + loginGate?: LoginGateOptions; loadResources: () => Promise<{ functions: ProjectData["functions"]; entities: ProjectData["entities"]; @@ -88,6 +91,12 @@ export async function createDevServer( }), ); + const devLogger = createDevLogger("backend", theme.styles.info); + + if (options.loginGate) { + app.use(createLoginGate(options.loginGate, devLogger)); + } + // Redirect OAuth routes to base44.app directly — proxying breaks the // redirect flow and session cookies set by the provider. const AUTH_ROUTE_PATTERN = /^\/api\/apps\/auth(\/|$)/; @@ -99,8 +108,6 @@ export async function createDevServer( next(); }); - const devLogger = createDevLogger("backend", theme.styles.info); - app.use("/api/apps/:appId/actors", (_req, res) => { const message = "Actors are not available in local development"; devLogger.error(message); diff --git a/packages/cli/src/cli/dev/dev-server/platform-paths.ts b/packages/cli/src/cli/dev/dev-server/platform-paths.ts new file mode 100644 index 000000000..c00273ea0 --- /dev/null +++ b/packages/cli/src/cli/dev/dev-server/platform-paths.ts @@ -0,0 +1,3 @@ +// Paths the platform owns. Everything else belongs to the app's own server — +// including its server routes, which frameworks conventionally put under /api. +export const PLATFORM_PATH_PATTERN = /^\/api\/(apps|app-logs)(\/|$)/; diff --git a/packages/cli/src/cli/dev/dev-server/remote-front-door.ts b/packages/cli/src/cli/dev/dev-server/remote-front-door.ts new file mode 100644 index 000000000..f919471ab --- /dev/null +++ b/packages/cli/src/cli/dev/dev-server/remote-front-door.ts @@ -0,0 +1,83 @@ +import type { IncomingMessage, Server, ServerResponse } from "node:http"; +import type { Socket } from "node:net"; +import cors from "cors"; +import express from "express"; +import getPort from "get-port"; +import { createProxyMiddleware } from "http-proxy-middleware"; +import { createDevLogger } from "@/cli/dev/createDevLogger.js"; +import { + type AppServerTarget, + createAppServerProxy, +} from "@/cli/dev/dev-server/routes/app-server.js"; +import { + createLoginGate, + type LoginGateOptions, +} from "@/cli/dev/dev-server/routes/login-gate.js"; +import { theme } from "@/cli/utils/index.js"; +import { PLATFORM_PATH_PATTERN } from "./platform-paths.js"; +import { REALTIME_PATH } from "./realtime.js"; + +const DEFAULT_PORT = 4400; + +interface RemoteFrontDoorOptions { + port?: number; + /** The live app, which answers the platform's paths. */ + siteUrl: string; + appServer: AppServerTarget; + loginGate?: LoginGateOptions; +} + +interface RemoteFrontDoor { + port: number; +} + +/** + * `base44 dev --remote`: the same single origin as the local front door, with + * the platform's paths answered by the live app instead of a local backend. + */ +export async function createRemoteFrontDoor( + options: RemoteFrontDoorOptions, +): Promise { + const port = + options.port ?? + (await getPort({ + port: process.env.IS_TEST === "true" ? undefined : DEFAULT_PORT, + })); + const logger = createDevLogger("front door", theme.styles.info); + const app = express(); + + app.use(cors({ origin: /^http:\/\/localhost(:\d+)?$/, credentials: true })); + if (options.loginGate) { + app.use(createLoginGate(options.loginGate, logger)); + } + + const liveApp = createProxyMiddleware({ + target: options.siteUrl, + changeOrigin: true, + }); + const appServerProxy = createAppServerProxy( + { ...options.appServer, serviceRole: false }, + logger, + ); + + app.use((req, res, next) => + PLATFORM_PATH_PATTERN.test(req.path) + ? liveApp(req, res, next) + : appServerProxy.middleware(req, res, next), + ); + + const server = await new Promise((resolve, reject) => { + const s = app.listen(port, "127.0.0.1", (err) => + err ? reject(err) : resolve(s), + ); + }); + server.on("upgrade", (req, socket, head) => { + if (req.url?.startsWith(REALTIME_PATH)) { + liveApp.upgrade(req, socket as Socket, head); + } else { + appServerProxy.upgrade(req, socket, head); + } + }); + + return { port }; +} diff --git a/packages/cli/src/cli/dev/dev-server/routes/app-server.ts b/packages/cli/src/cli/dev/dev-server/routes/app-server.ts index a216c0aaf..386688a4f 100644 --- a/packages/cli/src/cli/dev/dev-server/routes/app-server.ts +++ b/packages/cli/src/cli/dev/dev-server/routes/app-server.ts @@ -40,23 +40,28 @@ const PROXY_LOOP_MESSAGE = [ /** What a request into app code carries, in place of any copies of its own. */ function sdkHeaders( - appId: string, + target: AppServerTarget, host: string | undefined, ): Record { return { - "Base44-App-Id": appId, + "Base44-App-Id": target.appId, // The front door is plain HTTP on localhost, so its origin is the host the // request reached. "Base44-Api-Url": `http://${host}`, // Production injects a service-role token on every request into app code, - // so `asServiceRole` works server-side for anonymous visitors too. - "Base44-Service-Authorization": createServiceAuthorizationHeader(), + // so `asServiceRole` works server-side for anonymous visitors too. Only the + // local backend accepts one minted here. + ...(target.serviceRole === false + ? {} + : { "Base44-Service-Authorization": createServiceAuthorizationHeader() }), [FORWARDED_HEADER]: "1", }; } export interface AppServerTarget { appId: string; + /** Inject a locally minted service-role token. Defaults to true. */ + serviceRole?: boolean; /** Resolves with the app dev server's origin once it announces itself. */ waitForOrigin: () => Promise; } @@ -88,7 +93,7 @@ export function createAppServerProxy( proxyReq.removeHeader(header); } for (const [name, value] of Object.entries( - sdkHeaders(target.appId, req.headers.host), + sdkHeaders(target, req.headers.host), )) { proxyReq.setHeader(name, value); } @@ -172,7 +177,7 @@ export function createAppServerProxy( { headers: { ...forwarded, - ...sdkHeaders(target.appId, req.headers.host), + ...sdkHeaders(target, req.headers.host), }, }, ); diff --git a/packages/cli/src/cli/dev/dev-server/routes/login-gate.ts b/packages/cli/src/cli/dev/dev-server/routes/login-gate.ts new file mode 100644 index 000000000..7016825c9 --- /dev/null +++ b/packages/cli/src/cli/dev/dev-server/routes/login-gate.ts @@ -0,0 +1,164 @@ +import type { Request, Response } from "express"; +import { Router, urlencoded } from "express"; +import type { DevLogger } from "@/cli/dev/createDevLogger.js"; + +const SESSION_COOKIE = "base44_dev_session"; +const LOGIN_PATH = "/login"; +const LOGOUT_PATH = "/api/apps/auth/logout"; + +export interface LoginGateOptions { + /** The Base44 account the developer is signed in to the CLI with. */ + email: string; + /** Where the app's data comes from, as shown on the sign-in page. */ + dataSource: string; + /** Mints the access token the app's SDK stores once the developer signs in. */ + mintToken: () => Promise; +} + +/** + * Stands in for the platform's login in front of a private or workspace app: + * page loads without a session are sent to the CLI's own sign-in page, which + * hands the app an access token the way the hosted login does + * (`?access_token=` on the way back) and keeps it in a session cookie. Like the + * platform's, that cookie identifies the visitor on requests that carry no + * token of their own, such as page loads the app renders on the server. + */ +export function createLoginGate( + { email, dataSource, mintToken }: LoginGateOptions, + logger: DevLogger, +): Router { + const router = Router(); + + router.get(LOGIN_PATH, (req, res) => { + const fromUrl = sameOriginTarget(req, req.query.from_url).toString(); + res.type("html").send(loginPage(email, dataSource, fromUrl)); + }); + + router.post(LOGIN_PATH, urlencoded({ extended: false }), async (req, res) => { + let token: string; + try { + token = await mintToken(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error(`Sign-in failed: ${message}`); + res.status(502).type("text").send(`Sign-in failed: ${message}`); + return; + } + const target = sameOriginTarget(req, req.body?.from_url); + target.searchParams.set("access_token", token); + res.setHeader( + "Set-Cookie", + `${SESSION_COOKIE}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax`, + ); + res.redirect(303, target.toString()); + }); + + router.get(LOGOUT_PATH, (req, res) => { + res.setHeader( + "Set-Cookie", + `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`, + ); + res.redirect(sameOriginTarget(req, req.query.from_url).toString()); + }); + + router.use((req, res, next) => { + const token = sessionToken(req); + if (!token) { + if (isPageLoad(req)) { + redirectToLogin(req, res); + return; + } + } else if (!req.headers.authorization) { + req.headers.authorization = `Bearer ${token}`; + } + next(); + }); + + return router; +} + +// API and media requests carry their own credentials; only pages are gated. +const UNGATED_PATH_PATTERN = /^\/(api|media)\//; + +function isPageLoad(req: Request): boolean { + return ( + req.method === "GET" && + !UNGATED_PATH_PATTERN.test(req.path) && + (req.headers.accept ?? "").includes("text/html") + ); +} + +function sessionToken(req: Request): string | undefined { + for (const cookie of (req.headers.cookie ?? "").split(";")) { + const [name, ...value] = cookie.trim().split("="); + if (name === SESSION_COOKIE && value.length > 0) { + return decodeURIComponent(value.join("=")) || undefined; + } + } + return undefined; +} + +function redirectToLogin(req: Request, res: Response): void { + const login = new URL(LOGIN_PATH, origin(req)); + login.searchParams.set( + "from_url", + new URL(req.originalUrl, origin(req)).href, + ); + res.redirect(login.toString()); +} + +function origin(req: Request): string { + return `http://${req.headers.host}`; +} + +/** A redirect target on this origin only, so a link cannot send the token elsewhere. */ +function sameOriginTarget(req: Request, fromUrl: unknown): URL { + const base = origin(req); + if (typeof fromUrl === "string") { + try { + const target = new URL(fromUrl, base); + if (target.origin === base && target.pathname !== LOGIN_PATH) { + return target; + } + } catch { + // Not a URL: fall through to the app's root. + } + } + return new URL("/", base); +} + +const escapeHtml = (value: string) => + value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); + +function loginPage(email: string, dataSource: string, fromUrl: string): string { + return ` + + + + +Sign in + + + +
+

Sign in to this private app

+

In local development you sign in as the Base44 account the CLI is signed in with. The app uses ${escapeHtml(dataSource)}.

+
+ + +
+
+ +`; +} diff --git a/packages/cli/src/core/project/api.ts b/packages/cli/src/core/project/api.ts index 48ba5b551..439884e6b 100644 --- a/packages/cli/src/core/project/api.ts +++ b/packages/cli/src/core/project/api.ts @@ -12,6 +12,7 @@ import type { } from "@/core/project/schema.js"; import { AppDetailSchema, + AppPublicSettingsSchema, CreateProjectResponseSchema, ProjectsResponseSchema, } from "@/core/project/schema.js"; @@ -104,6 +105,33 @@ export async function listProjects(): Promise { return result.data; } +/** The app's visibility as the server has it, from its `public_settings`. */ +export async function getAppVisibility(appId: string): Promise { + let response: KyResponse; + try { + response = await base44Client.get(`api/apps/${appId}`, { + searchParams: { fields: "id,public_settings" }, + }); + } catch (error) { + throw await ApiError.fromHttpError(error, "fetching app visibility"); + } + + const result = AppPublicSettingsSchema.safeParse(await response.json()); + if (!result.success) { + throw new SchemaValidationError( + "Invalid response from server", + result.error, + ); + } + + const settings = result.data.public_settings; + return ( + (Object.keys(PUBLIC_SETTINGS) as Visibility[]).find( + (visibility) => PUBLIC_SETTINGS[visibility] === settings, + ) ?? "public" + ); +} + /** * Fetch a single app's details, including the workspace (`organizationId`) it * currently belongs to. diff --git a/packages/cli/src/core/project/schema.ts b/packages/cli/src/core/project/schema.ts index 76080d5dd..6b12f1236 100644 --- a/packages/cli/src/core/project/schema.ts +++ b/packages/cli/src/core/project/schema.ts @@ -94,6 +94,10 @@ export const AppDetailSchema = z export type AppDetail = z.infer; +export const AppPublicSettingsSchema = z.looseObject({ + public_settings: z.string().nullish(), +}); + export const ProjectSchema = z .object({ id: z.string(), diff --git a/packages/cli/tests/cli/dev-app-server.spec.ts b/packages/cli/tests/cli/dev-app-server.spec.ts index 9ba61c340..e39b14013 100644 --- a/packages/cli/tests/cli/dev-app-server.spec.ts +++ b/packages/cli/tests/cli/dev-app-server.spec.ts @@ -155,3 +155,102 @@ describe("dev command fronting the app dev server", () => { t.expectResult(result).toSucceed(); }); }); + +describe("dev command in front of a private app", () => { + const t = setupCLITests(); + const email = "dev@example.com"; + + const startDevServer = async (fixtureName = "with-private-app-server") => { + await t.givenLoggedInWithProject(fixture(fixtureName), { + email, + name: "Dev", + }); + const handle = await t.runLive("dev"); + const url = await waitForDevServer(handle); + return { handle, url }; + }; + + const page = { Accept: "text/html" }; + + it("sends a page load without a session to the CLI's sign-in page", async () => { + const { handle, url } = await startDevServer(); + + const response = await fetch(`${url}/notes`, { + headers: page, + redirect: "manual", + }); + + expect(response.status).toBe(302); + const login = new URL(response.headers.get("location") ?? ""); + expect(login.pathname).toBe("/login"); + expect(login.searchParams.get("from_url")).toBe(`${url}/notes`); + + const loginPage = await fetch(login, { headers: page }); + expect(await loginPage.text()).toContain(`Continue as ${email}`); + + const result = await handle.stop(); + t.expectResult(result).toSucceed(); + }); + + it("signs the developer in, hands the app a token, and sends it with later requests", async () => { + const { handle, url } = await startDevServer(); + + const signIn = await fetch(`${url}/login`, { + method: "POST", + body: new URLSearchParams({ from_url: `${url}/notes` }), + redirect: "manual", + }); + + expect(signIn.status).toBe(303); + const back = new URL(signIn.headers.get("location") ?? ""); + expect(back.pathname).toBe("/notes"); + const token = back.searchParams.get("access_token") ?? ""; + expect(jwt.decode(token)?.sub).toBe(email); + const cookie = (signIn.headers.get("set-cookie") ?? "").split(";")[0]; + expect(cookie).toMatch(/^base44_dev_session=/); + + const response = await fetch(`${url}/notes`, { + headers: { ...page, Cookie: cookie }, + }); + expect(response.status).toBe(200); + const { headers } = (await response.json()) as EchoedRequest; + expect(headers.authorization).toBe(`Bearer ${token}`); + + const result = await handle.stop(); + t.expectResult(result).toSucceed(); + }); + + it("ends the session on the SDK's logout", async () => { + const { handle, url } = await startDevServer(); + + const response = await fetch( + `${url}/api/apps/auth/logout?from_url=${encodeURIComponent(`${url}/`)}`, + { redirect: "manual" }, + ); + + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe(`${url}/`); + expect(response.headers.get("set-cookie")).toMatch( + /^base44_dev_session=;.*Max-Age=0/, + ); + + const result = await handle.stop(); + t.expectResult(result).toSucceed(); + }); + + it("leaves sign-in to a public app", async () => { + const { handle, url } = await startDevServer("with-app-server"); + + const response = await fetch(`${url}/login`, { + headers: page, + redirect: "manual", + }); + + expect(response.status).toBe(200); + const { url: forwardedUrl } = (await response.json()) as EchoedRequest; + expect(forwardedUrl).toBe("/login"); + + const result = await handle.stop(); + t.expectResult(result).toSucceed(); + }); +}); diff --git a/packages/cli/tests/cli/dev.spec.ts b/packages/cli/tests/cli/dev.spec.ts index 929599373..f7ff57f17 100644 --- a/packages/cli/tests/cli/dev.spec.ts +++ b/packages/cli/tests/cli/dev.spec.ts @@ -1,3 +1,4 @@ +import getPort from "get-port"; import jwt from "jsonwebtoken"; import { describe, expect, it } from "vitest"; import { @@ -102,17 +103,20 @@ describe("dev command", () => { expect(output).toContain("Backend running on http://localhost:"); }); - it("--remote runs the serveCommand against the app's published URL", async () => { + it("--remote fronts the serveCommand, with the live app answering the platform", async () => { await t.givenLoggedInWithProject(fixture("with-serve-command")); t.api.mockSiteUrl({ url: "https://my-app.base44.app" }); const handle = await t.runLive("dev", "--remote"); + const url = await waitForDevServer(handle); await handle.waitForOutput(/SERVE_APP=/); await handle.stop(); const output = handle.stdout.join(""); expect(output).toContain(`SERVE_APP=${t.api.appId}`); - expect(output).toContain("URL=https://my-app.base44.app"); + // The app's SDK talks to the front door, which forwards to the live app. + expect(output).toContain(`URL=${url}`); + expect(output).toContain("on the data of https://my-app.base44.app"); expect(output).not.toContain("Backend running on"); }); @@ -126,15 +130,16 @@ describe("dev command", () => { t.expectResult(result).toContain("site URL"); }); - it("--remote rejects --port", async () => { + it("--remote serves its front door on --port", async () => { await t.givenLoggedInWithProject(fixture("with-serve-command")); + t.api.mockSiteUrl({ url: "https://my-app.base44.app" }); + const port = await getPort(); - const result = await t.run("dev", "--remote", "--port", "5000"); + const handle = await t.runLive("dev", "--remote", "--port", String(port)); + const url = await waitForDevServer(handle); + await handle.stop(); - t.expectResult(result).toFail(); - t.expectResult(result).toContain( - "--port applies to the local backend, which --remote does not start", - ); + expect(url).toBe(`http://localhost:${port}`); }); it("--remote fails without a site.serveCommand", async () => { diff --git a/packages/cli/tests/fixtures/with-private-app-server/app-server.cjs b/packages/cli/tests/fixtures/with-private-app-server/app-server.cjs new file mode 100644 index 000000000..cb5d2fbca --- /dev/null +++ b/packages/cli/tests/fixtures/with-private-app-server/app-server.cjs @@ -0,0 +1,39 @@ +// Stands in for a framework dev server: announces its URL the way Vite does +// (with the port bolded), then echoes what each request arrived with. +const { createHash } = require("node:crypto"); +const { createServer } = require("node:http"); + +const server = createServer((req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ url: req.url, headers: req.headers })); +}); + +// Stands in for the framework's HMR socket: completes the handshake, then sends +// one text frame naming what the upgrade arrived with. +server.on("upgrade", (req, socket) => { + // A real dev server survives a peer resetting the socket; so must this one. + socket.on("error", () => {}); + const accept = createHash("sha1") + .update(req.headers["sec-websocket-key"] + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11") + .digest("base64"); + socket.write( + "HTTP/1.1 101 Switching Protocols\r\n" + + "Upgrade: websocket\r\n" + + "Connection: Upgrade\r\n" + + `Sec-WebSocket-Accept: ${accept}\r\n\r\n`, + ); + const payload = Buffer.from( + JSON.stringify({ url: req.url, headers: req.headers }), + ); + // An unmasked server text frame; payloads here stay under 64 KiB. + const header = + payload.length < 126 + ? Buffer.from([0x81, payload.length]) + : Buffer.from([0x81, 126, payload.length >> 8, payload.length & 0xff]); + socket.write(Buffer.concat([header, payload])); +}); + +server.listen(0, "127.0.0.1", () => { + const { port } = server.address(); + console.log(` \u001b[32m➜\u001b[39m Local: \u001b[36mhttp://localhost:\u001b[1m${port}\u001b[22m/\u001b[39m`); +}); diff --git a/packages/cli/tests/fixtures/with-private-app-server/base44/config.jsonc b/packages/cli/tests/fixtures/with-private-app-server/base44/config.jsonc new file mode 100644 index 000000000..4fd21b848 --- /dev/null +++ b/packages/cli/tests/fixtures/with-private-app-server/base44/config.jsonc @@ -0,0 +1,7 @@ +{ + "name": "Private App Server Project", + "visibility": "private", + "site": { + "serveCommand": "node app-server.cjs" + } +}