From 761d7349c9ee904f30fa25d7421f2dc933b3a8a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 12:21:39 +0000 Subject: [PATCH] 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" + } +}