diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..836b408 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,5 @@ +# Code of Conduct + +Be kind and respectful. This project follows a standard code of conduct: be inclusive, avoid personal attacks, and report violations to the maintainers. + +This is a short version — for serious projects prefer the full Contributor Covenant. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..1eb176a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,28 @@ +# Contributing + +Thanks for your interest in contributing! The following guidelines help keep the project stable and easy to maintain. + +Workflow + +1. Open an issue first for substantial or breaking changes to get feedback from maintainers. +2. Create a branch for each change (e.g., `fix/runtime-typing`, `feat/logger-adapter`). +3. Keep changes small and atomic — one logical change per PR. +4. Add or update tests for behavior you change. +5. If your change affects the public API, document it clearly (CHANGELOG) and discuss versioning. +6. Open a Pull Request with a clear description, rationale, and testing instructions. + +Code style and expectations + +- Use TypeScript with `strict` mode enabled. Prefer `unknown` over `any` and perform explicit narrowing/casts when necessary. +- Keep public exports stable: avoid renaming/removing exported functions without prior discussion. +- Write clear, concise commit messages (short summary + explanation why). + +PR checklist + +- [ ] Branch name follows pattern and describes intent. +- [ ] Tests added or updated where relevant. +- [ ] Linted / code formatted (if linting/formatting is configured). +- [ ] Changes described in PR body and linked to any related issue. +- [ ] If public API changed, document it and include a migration note. + +If you're unsure about implementation details or the scope of a change, open an issue or ask in the PR comments — maintainers will help. diff --git a/Learn.md b/Learn.md new file mode 100644 index 0000000..36ca105 --- /dev/null +++ b/Learn.md @@ -0,0 +1,59 @@ +# Learn ReactServe + +This guide explains core concepts, runtime behavior, and practical tips for working on the codebase without introducing breaking changes. + +Overview + +- ReactServe is a small monorepo containing the core runtime (`packages/react-serve-js`), a project scaffolder (`packages/create-react-serve`), and several example apps in `examples/`. +- The core idea: authors write backend logic using a JSX-like tree (components such as ``, ``, ``, ``, and ``). The runtime converts that tree into Express routes at startup. + +Design and Principles + +- Keep public API stable: avoid renaming or removing exported symbols (`serve`, `useRoute`, `useSetContext`, `useContext`, `Middleware` type, etc.). +- Prefer small, atomic changes so users can update incrementally. +- Favor safety in typing: prefer `unknown` (or specific interfaces) instead of `any`, then narrow/cast locally where necessary. +- Centralize cross-cutting concerns (logging, error handling) so behavior is consistent and easy to change. + +Runtime model (high level) + +- At startup, `serve(element)` walks the JSX tree and collects routes, middleware and global config. +- Route handlers are stored as functions (typically an async function returning a `Response` element or primitive). A `createExpressHandler` wrapper sets up a `routeContext`, runs middlewares in sequence, awaits the final output and normalizes it into an HTTP response. +- Middlewares are executed sequentially and can call `next()` to advance. The middleware can return a `Response` to short-circuit. + +Key API summary + +- `serve(element: ReactNode): Server` — boot the server from an `App` root element. +- `App({ children, port?, cors? })` — root component for configuration. +- `Route({ path, method, middleware?, children })` — defines an endpoint. `children` is typically an async handler returning a `Response` element or primitive. +- `Response({ json?, status?, text?, html?, headers?, redirect? })` — helper element for sending replies. +- `Middleware({ use })` — wrapper to include middleware functions inside `RouteGroup` or at top-level. +- Hooks available inside handlers/middleware: + - `useRoute()` — returns the runtime route context (request, response, params, query, body). (Note: the runtime uses a typed `RouteContext` internally; prefer to treat fields as `unknown` and narrow them.) + - `useSetContext(key, value)` / `useContext(key)` — per-request middleware context and global context. + +Runtime types and safety + +- The runtime manipulates dynamic JS shapes (JSX runtime objects). To avoid unsafe usage of `any`, the code uses `unknown` or `Record` in public internals and narrows/casts locally only when necessary. This reduces accidental runtime errors while keeping the code compatible with consumer usage. + +Logging + +- A minimal `logger` utility lives in `packages/react-serve-js/src/logger.ts` and is used by the core runtime. It provides `info`, `warn`, `error`, and `debug` methods and can be disabled via the `REACT_SERVE_LOG` environment variable. +- Rationale: centralizing logging makes it easy to switch implementations (for example to `pino` or `winston`) and to control verbosity in production. + +Error handling and best practices + +- The runtime catches handler errors and returns a 500 JSON error when possible. Consider extracting a shared error-handler utility for improved observability and consistent error shapes. +- Validate external inputs (request body, params) inside handlers using a validator library (Zod/ajv/Joi) when building real endpoints. + +Development workflow + +- Install dependencies at the repo root: `npm install`. +- Build the core package: `npm run build --workspace=packages/react-serve-js`. +- Run the basic example in dev mode: `npm run dev --workspace=examples/basic`. +- To silence runtime logging for the session: + - PowerShell (session only): `$env:REACT_SERVE_LOG = 'false'` then run the dev script. + +Testing suggestions + +- Add unit tests for the route parser (`processElement`) to ensure routes and middleware are discovered correctly. +- Add tests for middleware ordering and short-circuit behavior. diff --git a/examples/basic/backend.tsx b/examples/basic/backend.tsx index 609cf67..204cf02 100644 --- a/examples/basic/backend.tsx +++ b/examples/basic/backend.tsx @@ -11,6 +11,7 @@ import { type MiddlewareFunction, //@ts-ignore } from "../../packages/react-serve-js/src"; +import logger from "../../packages/react-serve-js/src/logger"; const mockUsers = [ { id: 1, name: "John Doe", email: "john@example.com" }, @@ -44,7 +45,7 @@ const authMiddleware: MiddlewareFunction = (req, next) => { // Logging middleware example const loggingMiddleware: MiddlewareFunction = (req, next) => { - console.log(`[${new Date().toISOString()}] ${req.method} ${req.path}`); + logger.info(`[${new Date().toISOString()}] ${req.method} ${req.path}`); // Add request timestamp to context useSetContext("requestTimestamp", Date.now()); @@ -54,7 +55,7 @@ const loggingMiddleware: MiddlewareFunction = (req, next) => { // Route-specific middleware example const slowRouteMiddleware: MiddlewareFunction = (req, next) => { - console.log(`⏱️ Slow route accessed: ${req.path}`); + logger.info(`⏱️ Slow route accessed: ${req.path}`); // Simulate some processing time useSetContext("processStartTime", Date.now()); @@ -64,9 +65,7 @@ const slowRouteMiddleware: MiddlewareFunction = (req, next) => { // Another route-specific middleware const adminLogMiddleware: MiddlewareFunction = (req, next) => { - console.log( - `🔐 Admin route accessed: ${req.path} at ${new Date().toISOString()}` - ); + logger.info(`🔐 Admin route accessed: ${req.path} at ${new Date().toISOString()}`); useSetContext("adminAccess", true); @@ -94,7 +93,7 @@ export default function Backend() { {/* Route with individual middleware */} {async () => { - const processStart = useContext("processStartTime"); + const processStart = (useContext("processStartTime") as number) || 0; const processingTime = Date.now() - processStart; return ( @@ -208,12 +207,12 @@ export default function Backend() { {async () => { - const user = useContext("user"); + const user = useContext("user") as Record | null; const timestamp = useContext("requestTimestamp"); return ( diff --git a/packages/react-serve-js/src/logger.ts b/packages/react-serve-js/src/logger.ts new file mode 100644 index 0000000..bcd7400 --- /dev/null +++ b/packages/react-serve-js/src/logger.ts @@ -0,0 +1,20 @@ +const LOG_ENABLED = process.env.REACT_SERVE_LOG !== "false"; + +export const logger = { + info: (...args: unknown[]) => { + if (LOG_ENABLED) console.log(...args); + }, + warn: (...args: unknown[]) => { + if (LOG_ENABLED) console.warn(...args); + }, + error: (...args: unknown[]) => { + // Always show errors + console.error(...args); + }, + debug: (...args: unknown[]) => { + if (process.env.NODE_ENV !== "production" && LOG_ENABLED) + console.debug(...args); + }, +}; + +export default logger; diff --git a/packages/react-serve-js/src/runtime.ts b/packages/react-serve-js/src/runtime.ts index 6208b4f..4957809 100644 --- a/packages/react-serve-js/src/runtime.ts +++ b/packages/react-serve-js/src/runtime.ts @@ -1,31 +1,34 @@ +import cors from "cors"; import express, { - Request, Response as ExpressResponse, + Request, RequestHandler, } from "express"; -import { ReactNode } from "react"; import { watch } from "fs"; -import cors from "cors"; +import { ReactNode } from "react"; +import logger from "./logger"; // Context to hold req/res for useRoute() and middleware context -let routeContext: { +interface RouteContext { req: Request; res: ExpressResponse; - params: any; - query: any; - body: any; - middlewareContext: Map; -} | null = null; + params: Record; + query: Record | unknown; + body: unknown; + middlewareContext: Map; +} + +let routeContext: RouteContext | null = null; // Global context that can be used from anywhere -const globalContext = new Map(); +const globalContext = new Map(); export function useRoute() { if (!routeContext) throw new Error("useRoute must be used inside a Route"); return routeContext; } -export function useSetContext(key: string, value: any) { +export function useSetContext(key: string, value: unknown) { if (routeContext) { // If we're inside a route/middleware, use the route context routeContext.middlewareContext.set(key, value); @@ -50,7 +53,10 @@ export function useContext(key: string) { } // Middleware type -export type Middleware = (req: Request, next: () => any) => any; +export type Middleware = ( + req: Request, + next: () => Promise +) => Promise | unknown; // Internal store for routes, middlewares and config const routes: { @@ -68,7 +74,7 @@ let appConfig: { // Component processor function processElement( - element: any, + element: unknown, pathPrefix: string = "", middlewares: Middleware[] = [], parseBodyInherited: boolean = false, @@ -82,22 +88,21 @@ function processElement( return; } - if (typeof element === "object") { + if (typeof element === "object" && element !== null) { // Handle React elements with function components - if (typeof element.type === "function") { + // element shape is dynamic (type/props), use runtime casts + const el: any = element; + if (typeof el.type === "function") { // Call the function component to get its JSX result - const result = element.type(element.props || {}); + const result = el.type(el.props || {}); processElement(result, pathPrefix, middlewares, parseBodyInherited); return; } - if (element.type) { - if ( - element.type === "App" || - (element.type && element.type.name === "App") - ) { + if (el.type) { + if (el.type === "App" || (el.type && el.type.name === "App")) { // Extract app configuration - const props = element.props || {}; + const props = el.props || {}; appConfig = { port: props.port || 9000, cors: props.cors, @@ -106,11 +111,11 @@ function processElement( } if ( - element.type === "RouteGroup" || - (element.type && element.type.name === "RouteGroup") + el.type === "RouteGroup" || + (el.type && el.type.name === "RouteGroup") ) { // Handle RouteGroup component - const props = element.props || {}; + const props = el.props || {}; const groupPrefix = props.prefix ? `${pathPrefix}${props.prefix}` : pathPrefix; @@ -168,11 +173,8 @@ function processElement( return; } - if ( - element.type === "Route" || - (element.type && element.type.name === "Route") - ) { - const props = element.props || {}; + if (el.type === "Route" || (el.type && el.type.name === "Route")) { + const props = el.props || {}; if (props.path && props.children) { if (!props.method) { throw new Error( @@ -211,17 +213,13 @@ function processElement( } // Process children for non-RouteGroup elements - if (element.props && element.props.children) { - if (Array.isArray(element.props.children)) { - element.props.children.forEach((child: any) => - processElement(child, pathPrefix, middlewares, parseBodyInherited), - ); - } else { - processElement( - element.props.children, - pathPrefix, - middlewares, - parseBodyInherited, + if (el.props && el.props.children) { + const children = Array.isArray(el.props.children) + ? el.props.children + : [el.props.children]; + + children.forEach((child: any) => + processElement(child, pathPrefix, middlewares, parseBodyInherited) ); } } @@ -300,10 +298,10 @@ export function serve(element: ReactNode) { routeContext = { req, res, - params: req.params, - query: req.query, + params: req.params as Record, + query: req.query as Record, body: req.body, - middlewareContext: new Map(), + middlewareContext: new Map(), }; try { @@ -323,7 +321,7 @@ export function serve(element: ReactNode) { const output = await executeNextMiddleware(); sendResponseFromOutput(res, output); } catch (error) { - console.error("Route handler error:", error); + logger.error("Route handler error:", error); if (!res.headersSent) { res.status(500).json({ error: "Internal server error" }); } @@ -378,7 +376,7 @@ export function serve(element: ReactNode) { ); register(route.path, ...handlers); } else { - console.warn(`Unsupported HTTP method: ${route.method}`); + logger.warn(`Unsupported HTTP method: ${route.method}`); } } @@ -387,7 +385,7 @@ export function serve(element: ReactNode) { if (methodsByPath[path] && !methodsByPath[path].includes(req.method)) { res.set("Allow", methodsByPath[path].join(", ")); - console.log( + logger.warn( `\n🚫 [405 Method Not Allowed]\n` + ` ✦ Path: ${path}\n` + ` ✦ Tried: ${req.method}\n` + @@ -430,10 +428,10 @@ export function serve(element: ReactNode) { routeContext = { req, res, - params: req.params, - query: req.query, + params: req.params as Record, + query: req.query as Record, body: req.body, - middlewareContext: new Map(), + middlewareContext: new Map(), }; try { let middlewareIndex = 0; @@ -449,7 +447,7 @@ export function serve(element: ReactNode) { const output = await executeNextMiddleware(); sendResponseFromOutput(res, output); } catch (error) { - console.error("Wildcard route handler error:", error); + logger.error("Wildcard route handler error:", error); if (!res.headersSent) res.status(500).json({ error: "Internal server error" }); } finally { @@ -475,14 +473,14 @@ export function serve(element: ReactNode) { } const server = app.listen(port, () => { - console.log(`🚀 ReactServe running at http://localhost:${port}`); + logger.info(`🚀 ReactServe running at http://localhost:${port}`); if (process.env.NODE_ENV !== "production") { - console.log("🔥 Hot reload enabled - watching for file changes..."); + logger.info("🔥 Hot reload enabled - watching for file changes..."); } }); server.on("error", (err) => { - console.error("Server error:", err); + logger.error("Server error:", err); }); // Hot reload @@ -496,7 +494,7 @@ export function serve(element: ReactNode) { !filename.includes("node_modules") && !filename.includes(".git") ) { - console.log(`🔄 File changed: ${filename} - Restarting server...`); + logger.debug(`🔄 File changed: ${filename} - Restarting server...`); server.close(() => { process.exit(0); });