diff --git a/biome.json b/biome.json index db88f38c..b2d7a972 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.3.13/schema.json", + "$schema": "https://biomejs.dev/schemas/2.3.14/schema.json", "assist": { "actions": { "source": { @@ -13,7 +13,7 @@ } }, "files": { - "includes": ["**", "!**/dist/**", "!**/node_modules/**", "!**/tests/fixtures/**", "!**/*.d.ts"] + "includes": ["**", "!**/dist", "!**/node_modules", "!**/tests/fixtures", "!**/*.d.ts"] }, "formatter": { "enabled": true, diff --git a/bun.lock b/bun.lock index 88b8bc9f..17c528bd 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,7 @@ "@types/bun": "^1.2.15", "@types/common-tags": "^1.8.4", "@types/cors": "^2.8.19", + "@types/deno": "^2.5.0", "@types/ejs": "^3.1.5", "@types/express": "^5.0.6", "@types/json-schema": "^7.0.15", @@ -275,6 +276,8 @@ "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + "@types/deno": ["@types/deno@2.5.0", "", {}, "sha512-g8JS38vmc0S87jKsFzre+0ZyMOUDHPVokEJymSCRlL57h6f/FdKPWBXgdFh3Z8Ees9sz11qt9VWELU9Y9ZkiVw=="], + "@types/ejs": ["@types/ejs@3.1.5", "", {}, "sha512-nv+GSx77ZtXiJzwKdsASqi+YQ5Z7vwHsTP0JY2SiQgjGckkBRKZnk8nIM+7oUZ1VCtuTz0+By4qVR7fqzp/Dfg=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], diff --git a/deno-runtime/README.md b/deno-runtime/README.md new file mode 100644 index 00000000..a35b2299 --- /dev/null +++ b/deno-runtime/README.md @@ -0,0 +1,15 @@ +# Deno Runtime + +This folder contains code that runs in **Deno**, not Node.js. + +## Why separate? + +The CLI itself is a Node.js application, but backend functions are executed in Deno. This folder provides a local Deno server for development that mimics the production function runtime. + +## TypeScript Configuration + +This folder has its own `tsconfig.json` with Deno types (`@types/deno`) instead of Node types. This prevents type conflicts between the two runtimes. + +## Usage + +This server is started automatically by `base44 dev` to handle local function deployments. diff --git a/deno-runtime/main.ts b/deno-runtime/main.ts new file mode 100644 index 00000000..d6c6874a --- /dev/null +++ b/deno-runtime/main.ts @@ -0,0 +1,72 @@ +/** + * Deno Function Wrapper + * + * This script is executed by Deno to run user functions. + * It patches Deno.serve to inject a dynamic port before importing the user's function. + * + * Environment variables: + * - FUNCTION_PATH: Absolute path to the user's function entry file + * - FUNCTION_PORT: Port number for the function to listen on + * - FUNCTION_NAME: Name of the function (for logging) + */ + +// Make this file a module for top-level await support +export {}; + +const functionPath = Deno.env.get("FUNCTION_PATH"); +const port = parseInt(Deno.env.get("FUNCTION_PORT") || "8000", 10); +const functionName = Deno.env.get("FUNCTION_NAME") || "unknown"; + +if (!functionPath) { + console.error("[wrapper] FUNCTION_PATH environment variable is required"); + Deno.exit(1); +} + +// Store the original Deno.serve +const originalServe = Deno.serve.bind(Deno); + +// Patch Deno.serve to inject our port and add onListen callback +// @ts-expect-error - We're intentionally overriding Deno.serve +Deno.serve = ( + optionsOrHandler: + | Deno.ServeOptions + | Deno.ServeHandler + | (Deno.ServeOptions & { handler: Deno.ServeHandler }), + maybeHandler?: Deno.ServeHandler, +): Deno.HttpServer => { + const onListen = () => { + // This message is used by FunctionManager to detect when the function is ready + console.log(`[${functionName}] Listening on http://localhost:${port}`); + }; + + // Handle the different Deno.serve signatures: + // 1. Deno.serve(handler) + // 2. Deno.serve(options, handler) + // 3. Deno.serve({ ...options, handler }) + if (typeof optionsOrHandler === "function") { + // Signature: Deno.serve(handler) + return originalServe({ port, onListen }, optionsOrHandler); + } + + if (maybeHandler) { + // Signature: Deno.serve(options, handler) + return originalServe({ ...optionsOrHandler, port, onListen }, maybeHandler); + } + + // Signature: Deno.serve({ ...options, handler }) + const options = optionsOrHandler as Deno.ServeOptions & { + handler: Deno.ServeHandler; + }; + return originalServe({ ...options, port, onListen }); +}; + +console.log(`[${functionName}] Starting function from ${functionPath}`); + +// Dynamically import the user's function +// The function will call Deno.serve which is now patched to use our port +try { + await import(functionPath); +} catch (error) { + console.error(`[${functionName}] Failed to load function:`, error); + Deno.exit(1); +} diff --git a/deno-runtime/tsconfig.json b/deno-runtime/tsconfig.json new file mode 100644 index 00000000..2ef342c5 --- /dev/null +++ b/deno-runtime/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM"], + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "typeRoots": ["../node_modules/@types"], + "types": ["deno"] + }, + "include": ["./**/*"] +} diff --git a/docs/error-handling.md b/docs/error-handling.md index c1ff8a2d..dd566e00 100644 --- a/docs/error-handling.md +++ b/docs/error-handling.md @@ -114,6 +114,7 @@ See [api-patterns.md](api-patterns.md) for the full `ApiError.fromHttpError()` p | `FILE_READ_ERROR` | `FileReadError` | Can't read/write file | | `INTERNAL_ERROR` | `InternalError` | Unexpected error | | `TYPE_GENERATION_ERROR` | `TypeGenerationError` | Type generation failed for entity | +| `DEPENDENCY_NOT_FOUND` | `DependencyNotFoundError` | Required external tool not installed | ## CLIExitError (Special Case) diff --git a/infra/build.ts b/infra/build.ts index 44053706..611f6243 100644 --- a/infra/build.ts +++ b/infra/build.ts @@ -1,8 +1,6 @@ import { watch } from "node:fs"; -import { copyFile } from "node:fs/promises"; -import { join } from "node:path"; +import type { BuildConfig } from "bun"; import chalk from "chalk"; -import { BuildConfig } from "bun"; const runBuild = async (config: BuildConfig) => { const defaultBuildOptions: Partial = { @@ -32,8 +30,13 @@ const runAllBuilds = async () => { entrypoints: ["./src/cli/index.ts"], outdir: "./dist/cli", }); + const denoRuntime = await runBuild({ + entrypoints: ["./deno-runtime/main.ts"], + outdir: "./dist/deno-runtime", + }); return { cli, + denoRuntime, }; }; @@ -46,18 +49,18 @@ if (process.argv.includes("--watch")) { const changeHandler = async ( event: "rename" | "change", - filename: string | null + filename: string | null, ) => { const time = new Date().toLocaleTimeString(); console.log(chalk.dim(`[${time}]`), chalk.gray(`${filename} ${event}d`)); - const { cli } = await runAllBuilds(); - for (const result of [cli]) { + const { cli, denoRuntime } = await runAllBuilds(); + for (const result of [cli, denoRuntime]) { if (result.success && result.outputs.length > 0) { console.log( chalk.green(` āœ“ Rebuilt`), chalk.dim(`→`), - formatOutput(result.outputs) + formatOutput(result.outputs), ); } } @@ -65,15 +68,16 @@ if (process.argv.includes("--watch")) { await runAllBuilds(); - for (const dir of ["./src"]) { + for (const dir of ["./src", "./deno-runtime"]) { watch(dir, { recursive: true }, changeHandler); } // Keep process alive await new Promise(() => {}); } else { - const { cli } = await runAllBuilds(); + const { cli, denoRuntime } = await runAllBuilds(); console.log(chalk.green.bold(`\nāœ“ Build complete\n`)); console.log(chalk.dim(" Output:")); console.log(` ${formatOutput(cli.outputs)}`); + console.log(` ${formatOutput(denoRuntime.outputs)}`); } diff --git a/knip.json b/knip.json index e0e4fa5b..9d6729e8 100644 --- a/knip.json +++ b/knip.json @@ -2,5 +2,6 @@ "$schema": "https://unpkg.com/knip@5/schema.json", "entry": ["src/cli/index.ts", "tests/**/testkit/index.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"], - "ignore": ["dist/**", "tests/fixtures/**"] + "ignore": ["dist/**", "tests/fixtures/**"], + "ignoreDependencies": ["@types/deno"] } diff --git a/package.json b/package.json index 6b7323df..5ce8cb12 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@types/bun": "^1.2.15", "@types/common-tags": "^1.8.4", "@types/cors": "^2.8.19", + "@types/deno": "^2.5.0", "@types/ejs": "^3.1.5", "@types/express": "^5.0.6", "@types/json-schema": "^7.0.15", diff --git a/src/cli/commands/dev.ts b/src/cli/commands/dev.ts index 6a28688c..fd046cf6 100644 --- a/src/cli/commands/dev.ts +++ b/src/cli/commands/dev.ts @@ -1,8 +1,11 @@ +import { dirname, join } from "node:path"; import { Command } from "commander"; import { createDevServer } from "@/cli/dev/dev-server/main"; +import type { CLIContext } from "@/cli/types.js"; import { runCommand, theme } from "@/cli/utils/index.js"; import type { RunCommandResult } from "@/cli/utils/runCommand.js"; -import type { CLIContext } from "../types.js"; +import { readProjectConfig } from "@/core/project/config.js"; +import { functionResource } from "@/core/resources/function/resource.js"; interface DevOptions { port?: string; @@ -10,7 +13,17 @@ interface DevOptions { async function devAction(options: DevOptions): Promise { const port = options.port ? Number(options.port) : undefined; - const { port: resolvedPort } = await createDevServer({ port }); + const { port: resolvedPort } = await createDevServer({ + port, + loadResources: async () => { + const { project } = await readProjectConfig(); + const configDir = dirname(project.configPath); + const functions = await functionResource.readAll( + join(configDir, project.functionsDir), + ); + return { functions }; + }, + }); return { outroMessage: `Dev server is available at ${theme.colors.links(`http://localhost:${resolvedPort}`)}`, diff --git a/src/cli/dev/createDevLogger.ts b/src/cli/dev/createDevLogger.ts new file mode 100644 index 00000000..aeec2d4b --- /dev/null +++ b/src/cli/dev/createDevLogger.ts @@ -0,0 +1,33 @@ +import { theme } from "@/cli/utils/theme"; + +type LogType = "log" | "error" | "warn"; + +export interface Logger { + log: (msg: string) => void; + error: (msg: string, err?: unknown) => void; + warn: (msg: string) => void; +} + +const colorByType: Record string> = { + error: theme.styles.error, + warn: theme.styles.warn, + log: (text: string) => text, +}; + +export function createDevLogger(): Logger { + const print = (type: LogType, msg: string) => { + const colorize = colorByType[type]; + console[type](colorize(msg)); + }; + + return { + log: (msg: string) => print("log", msg), + error: (msg: string, err?: unknown) => { + print("error", msg); + if (err) { + print("error", String(err)); + } + }, + warn: (msg: string) => print("warn", msg), + }; +} diff --git a/src/cli/dev/dev-server/function-manager.ts b/src/cli/dev/dev-server/function-manager.ts new file mode 100644 index 00000000..ac25357c --- /dev/null +++ b/src/cli/dev/dev-server/function-manager.ts @@ -0,0 +1,207 @@ +import type { ChildProcess } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import getPort from "get-port"; +import { + DependencyNotFoundError, + InternalError, + InvalidInputError, +} from "@/core/errors.js"; +import type { BackendFunction } from "@/core/resources/function/schema.js"; +import type { Logger } from "../createDevLogger"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const WRAPPER_PATH = join(__dirname, "../deno-runtime/main.js"); + +const READY_TIMEOUT = 30000; + +interface RunningFunction { + process: ChildProcess; + port: number; + ready: boolean; +} + +export class FunctionManager { + private functions: Map; + private running: Map = new Map(); + private starting: Map> = new Map(); + private logger: Logger; + + constructor(functions: BackendFunction[], logger: Logger) { + this.functions = new Map(functions.map((f) => [f.name, f])); + this.logger = logger; + + if (functions.length > 0) { + this.verifyDenoIsInstalled(); + } + } + + private verifyDenoIsInstalled(): void { + const result = spawnSync("deno", ["--version"]); + if (result.error) { + throw new DependencyNotFoundError("Deno is required to run functions", { + hints: [{ message: "Install Deno from https://deno.com/download" }], + }); + } + } + + getFunctionNames(): string[] { + return Array.from(this.functions.keys()); + } + + async ensureRunning(name: string): Promise { + const backendFunction = this.functions.get(name); + if (!backendFunction) { + throw new InvalidInputError(`Function "${name}" not found`, { + hints: [{ message: "Check available functions in your project" }], + }); + } + + const existing = this.running.get(name); + if (existing?.ready) { + return existing.port; + } + + const pending = this.starting.get(name); + if (pending) { + return pending; + } + + const promise = this.startFunction(name, backendFunction); + this.starting.set(name, promise); + + try { + return await promise; + } finally { + this.starting.delete(name); + } + } + + private async startFunction( + name: string, + backendFunction: BackendFunction, + ): Promise { + const port = await this.allocatePort(); + const process = this.spawnFunction(backendFunction, port); + + const runningFunc: RunningFunction = { + process, + port, + ready: false, + }; + + this.running.set(name, runningFunc); + this.setupProcessHandlers(name, process); + + return this.waitForReady(name, runningFunc); + } + + stopAll(): void { + for (const [name, { process }] of this.running) { + this.logger.log(`[dev-server] Stopping function: ${name}`); + process.kill(); + } + this.running.clear(); + this.starting.clear(); + } + + private async allocatePort(): Promise { + const usedPorts = Array.from(this.running.values()).map((r) => r.port); + return getPort({ exclude: usedPorts }); + } + + private spawnFunction(func: BackendFunction, port: number): ChildProcess { + this.logger.log( + `[dev-server] Spawning function "${func.name}" on port ${port}`, + ); + + const process = spawn("deno", ["run", "--allow-all", WRAPPER_PATH], { + env: { + ...globalThis.process.env, + FUNCTION_PATH: func.entryPath, + FUNCTION_PORT: String(port), + FUNCTION_NAME: func.name, + }, + stdio: ["pipe", "pipe", "pipe"], + }); + + return process; + } + + private setupProcessHandlers(name: string, process: ChildProcess): void { + // Pipe stdout with function name prefix + process.stdout?.on("data", (data: Buffer) => { + const lines = data.toString().trim().split("\n"); + for (const line of lines) { + this.logger.log(line); + } + }); + + // Pipe stderr with function name prefix + process.stderr?.on("data", (data: Buffer) => { + const lines = data.toString().trim().split("\n"); + for (const line of lines) { + this.logger.error(line); + } + }); + + process.on("exit", (code) => { + this.logger.log( + `[dev-server] Function "${name}" exited with code ${code}`, + ); + this.running.delete(name); + }); + + process.on("error", (error) => { + this.logger.error(`[dev-server] Function "${name}" error:`, error); + this.running.delete(name); + }); + } + + private waitForReady( + name: string, + runningFunc: RunningFunction, + ): Promise { + return new Promise((resolve, reject) => { + runningFunc.process.on("exit", (code) => { + if (!runningFunc.ready) { + clearTimeout(timeout); + reject( + new InternalError(`Function "${name}" exited with code ${code}`, { + hints: [{ message: "Check the function code for errors" }], + }), + ); + } + }); + + const timeout = setTimeout(() => { + runningFunc.process.kill(); + reject( + new InternalError( + `Function "${name}" failed to start within ${READY_TIMEOUT / 1000}s timeout`, + { + hints: [ + { message: "Check the function code for startup errors" }, + ], + }, + ), + ); + }, READY_TIMEOUT); + + const onData = (data: Buffer) => { + const output = data.toString(); + // We relay on the fact that logic in `deno-runtime/main.ts` will print `Listening on` when function is up and ready. + if (output.includes("Listening on")) { + runningFunc.ready = true; + clearTimeout(timeout); + runningFunc.process.stdout?.off("data", onData); + resolve(runningFunc.port); + } + }; + + runningFunc.process.stdout?.on("data", onData); + }); + } +} diff --git a/src/cli/dev/dev-server/main.ts b/src/cli/dev/dev-server/main.ts index 6e66ce81..4abb73aa 100644 --- a/src/cli/dev/dev-server/main.ts +++ b/src/cli/dev/dev-server/main.ts @@ -1,14 +1,20 @@ import type { Server } from "node:http"; +import { log as clackLog } from "@clack/prompts"; 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 { FunctionManager } from "@/cli/dev/dev-server/function-manager.js"; +import { createFunctionRouter } from "@/cli/dev/dev-server/routes/functions.js"; +import type { BackendFunction } from "@/core/resources/function/schema.js"; const DEFAULT_PORT = 4400; const BASE44_APP_URL = "https://base44.app"; interface DevServerOptions { port?: number; + loadResources: () => Promise<{ functions: BackendFunction[] }>; } interface DevServerResult { @@ -17,9 +23,12 @@ interface DevServerResult { } export async function createDevServer( - options: DevServerOptions = {}, + options: DevServerOptions, ): Promise { - const port = options.port ?? (await getPort({ port: DEFAULT_PORT })); + const { port: userPort } = options; + const port = userPort ?? (await getPort({ port: DEFAULT_PORT })); + + const { functions } = await options.loadResources(); const app = express(); @@ -46,6 +55,19 @@ export async function createDevServer( next(); }); + const devLogger = createDevLogger(); + + const functionManager = new FunctionManager(functions, devLogger); + + if (functionManager.getFunctionNames().length > 0) { + clackLog.info( + `Loaded functions: ${functionManager.getFunctionNames().join(", ")}`, + ); + + const functionRoutes = createFunctionRouter(functionManager, devLogger); + app.use("/api/apps/:appId/functions", functionRoutes); + } + app.use((req, res, next) => { return remoteProxy(req, res, next); }); @@ -63,6 +85,13 @@ export async function createDevServer( reject(err); } } else { + const shutdown = () => { + functionManager.stopAll(); + server.close(); + }; + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + resolve({ port, server, diff --git a/src/cli/dev/dev-server/routes/functions.ts b/src/cli/dev/dev-server/routes/functions.ts new file mode 100644 index 00000000..15742aa3 --- /dev/null +++ b/src/cli/dev/dev-server/routes/functions.ts @@ -0,0 +1,64 @@ +import type { IncomingMessage } from "node:http"; +import { ServerResponse } from "node:http"; +import type { Request, Response } from "express"; +import { Router } from "express"; +import { createProxyMiddleware } from "http-proxy-middleware"; +import type { Logger } from "../../createDevLogger.js"; +import type { FunctionManager } from "../function-manager.js"; + +export function createFunctionRouter( + manager: FunctionManager, + logger: Logger, +): Router { + const router = Router({ mergeParams: true }); + const portsByRequest = new WeakMap(); + + const proxy = createProxyMiddleware({ + router: (req) => `http://localhost:${portsByRequest.get(req)}`, + changeOrigin: true, + on: { + proxyReq: (proxyReq, req) => { + const xAppId = req.headers["x-app-id"]; + if (xAppId) { + proxyReq.setHeader("Base44-App-Id", xAppId as string); + } + proxyReq.setHeader( + "Base44-Api-Url", + `${(req as unknown as Request).protocol}://${req.headers.host}`, + ); + }, + error: (err, _req, res) => { + logger.error("Function 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 function", + details: err.message, + }), + ); + } + }, + }, + }); + + router.all( + "/:functionName", + async (req: Request<{ functionName: string }>, res: Response, next) => { + const { functionName } = req.params; + + try { + const port = await manager.ensureRunning(functionName); + portsByRequest.set(req, port); + next(); + } catch (error) { + logger.error("Function error:", error); + const message = error instanceof Error ? error.message : String(error); + res.status(500).json({ error: message }); + } + }, + proxy, + ); + + return router; +} diff --git a/src/cli/utils/theme.ts b/src/cli/utils/theme.ts index 5c2c527b..de50dabc 100644 --- a/src/cli/utils/theme.ts +++ b/src/cli/utils/theme.ts @@ -17,6 +17,8 @@ export const theme = { header: chalk.dim, bold: chalk.bold, dim: chalk.dim, + error: chalk.red, + warn: chalk.yellow, }, format: { errorContext(ctx: ErrorContext): string { diff --git a/src/core/errors.ts b/src/core/errors.ts index 11b449c5..c20ffa20 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -244,6 +244,22 @@ export class InvalidInputError extends UserError { readonly code = "INVALID_INPUT"; } +/** + * Thrown when a required external dependency is not installed (e.g., Deno, Git). + */ +export class DependencyNotFoundError extends UserError { + readonly code = "DEPENDENCY_NOT_FOUND"; + + constructor(message: string, options?: CLIErrorOptions) { + super(message, { + hints: options?.hints ?? [ + { message: "Install the required dependency and try again" }, + ], + cause: options?.cause, + }); + } +} + // ============================================================================ // System Errors // ============================================================================ diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 00000000..24d1ab80 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "lib": ["ES2022"], + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "noEmit": true + } +} diff --git a/tsconfig.json b/tsconfig.json index 4fef6fdc..65568386 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,15 +1,6 @@ { + "extends": "./tsconfig.base.json", "compilerOptions": { - "target": "ES2022", - "module": "ES2022", - "lib": ["ES2022"], - "moduleResolution": "node", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "noEmit": true, "types": ["node", "bun"], "baseUrl": ".", "paths": { @@ -17,5 +8,5 @@ } }, "include": ["src/**/*", "tests/**/*"], - "exclude": ["node_modules", "dist", "tests/fixtures"] + "exclude": ["node_modules", "dist", "tests/fixtures", "deno-runtime"] }