├── .github ├── codecov.yml └── workflows │ ├── ci.yml │ ├── deploy.yml │ ├── main.yml │ └── publish.yml ├── .gitignore ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── build.test.ts ├── build.ts ├── client.test.tsx ├── client.tsx ├── context.ts ├── deno.jsonc ├── deno.lock ├── dev.test.ts ├── dev.ts ├── docs ├── README.md ├── ci-cd.md ├── configuration.md ├── development-tools.md ├── error-handling.md ├── forms.md ├── getting-started.md ├── http-middleware.md ├── logging.md ├── metadata.md ├── routing.md ├── state-management.md ├── static-files.md ├── styling.md └── testing.md ├── env.test.ts ├── env.ts ├── error.test.tsx ├── error.tsx ├── example ├── .gitignore ├── build.ts ├── components │ ├── loading.test.tsx │ └── loading.tsx ├── data │ └── posts.ts ├── dev.ts ├── log.ts ├── main.ts ├── models │ └── posts.ts ├── public │ └── favicon.ico ├── routes │ ├── about.tsx │ ├── api │ │ └── blog │ │ │ └── posts.ts │ ├── blog │ │ ├── [id].ts │ │ ├── [id].tsx │ │ ├── index.ts │ │ ├── index.tsx │ │ └── main.tsx │ ├── index.tsx │ ├── main.ts │ └── main.tsx ├── services │ ├── posts.ts │ └── posts.tsx └── test-utils.tsx ├── global-jsdom.ts ├── log.ts ├── mod.test.tsx ├── mod.tsx ├── react.d.ts ├── server.test.ts ├── server.tsx ├── test-utils.test.ts └── test-utils.ts /.github/codecov.yml: -------------------------------------------------------------------------------- 1 | comment: false 2 | codecov: 3 | require_ci_to_pass: true 4 | coverage: 5 | status: 6 | project: 7 | default: 8 | informational: true 9 | ignore: 10 | - "example/public/build/**/*" 11 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | workflow_call: 4 | secrets: 5 | CODECOV_TOKEN: 6 | description: Repository codecov token 7 | required: false 8 | jobs: 9 | lint: 10 | name: Lint 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Clone repository 14 | uses: actions/checkout@v4 15 | - name: Setup deno 16 | uses: denoland/setup-deno@v1 17 | with: 18 | deno-version: v1.x 19 | - name: Lint source files 20 | run: deno lint 21 | format: 22 | name: Check formatting 23 | runs-on: ubuntu-latest 24 | steps: 25 | - name: Clone repository 26 | uses: actions/checkout@v4 27 | - name: Setup deno 28 | uses: denoland/setup-deno@v1 29 | with: 30 | deno-version: v1.x 31 | - name: Check formatting 32 | run: deno fmt --check 33 | test: 34 | name: Test ${{ matrix.os }} 35 | needs: [lint, format] 36 | runs-on: ${{ matrix.os }} 37 | strategy: 38 | matrix: 39 | os: [ubuntu-latest, windows-latest, macos-latest] 40 | fail-fast: true 41 | steps: 42 | - name: Clone repository 43 | uses: actions/checkout@v4 44 | - name: Setup deno 45 | uses: denoland/setup-deno@v1 46 | with: 47 | deno-version: v1.x 48 | - name: Build 49 | run: deno task build-dev 50 | - name: Run tests 51 | if: matrix.os != 'ubuntu-latest' 52 | run: deno task test 53 | - name: Run tests and collect coverage 54 | if: matrix.os == 'ubuntu-latest' 55 | run: deno task test --coverage 56 | - name: Generate coverage 57 | if: matrix.os == 'ubuntu-latest' 58 | run: deno coverage --lcov > coverage.lcov 59 | - name: Upload coverage 60 | if: matrix.os == 'ubuntu-latest' 61 | uses: codecov/codecov-action@v4 62 | with: 63 | fail_ci_if_error: true 64 | files: coverage.lcov 65 | env: 66 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 67 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | on: 3 | workflow_call: 4 | inputs: 5 | app-directory: 6 | description: The directory containing the application to deploy 7 | type: string 8 | default: '.' 9 | entrypoint: 10 | description: The entrypoint file for running your application 11 | type: string 12 | default: 'main.ts' 13 | project: 14 | description: The Deno Deploy project to deploy to 15 | required: true 16 | type: string 17 | jobs: 18 | deploy: 19 | name: Deploy 20 | runs-on: ubuntu-latest 21 | permissions: 22 | id-token: write 23 | contents: read 24 | steps: 25 | - name: Clone repository 26 | uses: actions/checkout@v4 27 | - name: Setup deno 28 | uses: denoland/setup-deno@main 29 | with: 30 | deno-version: v1.x 31 | - name: Build 32 | run: deno task build-prod 33 | - name: Deploy to Deno Deploy 34 | uses: denoland/deployctl@v1 35 | with: 36 | project: ${{ inputs.project }} 37 | entrypoint: '${{ inputs.app-directory }}/${{ inputs.entrypoint }}' 38 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI/CD 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | jobs: 8 | ci: 9 | name: CI 10 | uses: ./.github/workflows/ci.yml 11 | secrets: 12 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 13 | cd: 14 | name: CD 15 | needs: ci 16 | uses: ./.github/workflows/deploy.yml 17 | with: 18 | project: udibo-react-app 19 | app-directory: example 20 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: read 13 | id-token: write 14 | steps: 15 | - uses: actions/checkout@v4 16 | - run: npx jsr publish 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # VSCode 2 | .vscode/*.log 3 | 4 | # Coverage 5 | coverage 6 | 7 | # Node modules 8 | node_modules 9 | 10 | # WIP 11 | test-utils.tsx 12 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "request": "launch", 6 | "name": "build", 7 | "type": "node", 8 | "program": "${workspaceFolder}/build.ts", 9 | "cwd": "${workspaceFolder}/example", 10 | "runtimeExecutable": "deno", 11 | "runtimeArgs": [ 12 | "run", 13 | "-A", 14 | "--inspect-wait" 15 | ], 16 | "env": { 17 | "APP_ENV": "development", 18 | "NODE_ENV": "development" 19 | }, 20 | "attachSimplePort": 9229 21 | }, 22 | { 23 | "request": "launch", 24 | "name": "run", 25 | "type": "node", 26 | "program": "${workspaceFolder}/example/main.ts", 27 | "cwd": "${workspaceFolder}/example", 28 | "runtimeExecutable": "deno", 29 | "runtimeArgs": [ 30 | "run", 31 | "-A", 32 | "--inspect-wait" 33 | ], 34 | "env": { 35 | "APP_ENV": "development", 36 | "NODE_ENV": "development" 37 | }, 38 | "attachSimplePort": 9229 39 | }, 40 | { 41 | "request": "launch", 42 | "name": "test", 43 | "type": "node", 44 | "program": ".", 45 | "cwd": "${workspaceFolder}", 46 | "runtimeExecutable": "deno", 47 | "runtimeArgs": [ 48 | "test", 49 | "-A", 50 | "--inspect-wait" 51 | ], 52 | "env": { 53 | "APP_ENV": "test", 54 | "NODE_ENV": "development" 55 | }, 56 | "attachSimplePort": 9229 57 | } 58 | ] 59 | } 60 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true, 3 | "deno.lint": true, 4 | "deno.unstable": false, 5 | "deno.config": "./deno.jsonc", 6 | "files.associations": { 7 | "*.css": "tailwindcss" 8 | }, 9 | "editor.formatOnSave": true, 10 | "editor.defaultFormatter": "denoland.vscode-deno", 11 | "editor.quickSuggestions": { 12 | "strings": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "deno task: build", 6 | "type": "deno", 7 | "problemMatcher": [ 8 | "$deno" 9 | ], 10 | "command": "task", 11 | "args": [ 12 | "build" 13 | ] 14 | }, 15 | { 16 | "label": "deno task: run", 17 | "type": "deno", 18 | "problemMatcher": [ 19 | "$deno" 20 | ], 21 | "command": "task", 22 | "args": [ 23 | "run" 24 | ] 25 | }, 26 | { 27 | "label": "deno task: test", 28 | "type": "deno", 29 | "problemMatcher": [ 30 | "$deno" 31 | ], 32 | "command": "task", 33 | "args": [ 34 | "test" 35 | ] 36 | }, 37 | { 38 | "label": "deno task: test-watch", 39 | "type": "deno", 40 | "problemMatcher": [ 41 | "$deno" 42 | ], 43 | "command": "task", 44 | "args": [ 45 | "test-watch" 46 | ] 47 | }, 48 | { 49 | "label": "deno task: check", 50 | "type": "deno", 51 | "problemMatcher": [ 52 | "$deno" 53 | ], 54 | "command": "task", 55 | "args": [ 56 | "check" 57 | ] 58 | } 59 | ] 60 | } 61 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | To contribute, create an issue or comment on an existing issue that you would 4 | like to work on. All code contributions require test coverage and must pass 5 | formatting/lint checks before being approved and merged. 6 | 7 | ## Prerequisites 8 | 9 | You must install deno to be able to run the application locally. 10 | 11 | - https://deno.land 12 | 13 | ## Development 14 | 15 | For development, the tests and example application can be run with deno. 16 | 17 | To run the tests, use `deno task test` or `deno task test-watch`. 18 | 19 | To check formatting and run lint, use `deno task check`. 20 | 21 | To create a production build and to run the production build, use 22 | `deno task build` and `deno task run`. 23 | 24 | To run the application in development mode with live reloading, use 25 | `deno task dev`. 26 | 27 | This repository uses squash merging. If your branch is merged into main, you can 28 | get your branch back up to date with `deno task git-rebase`. Alternatively, you 29 | can delete your branch and create a new one off of the main branch. 30 | 31 | To learn more about working on this framework, see the [documentation](docs). 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Udibo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Udibo React App 2 | 3 | [![JSR](https://jsr.io/badges/@udibo/react-app)](https://jsr.io/@udibo/react-app) 4 | [![JSR Score](https://jsr.io/badges/@udibo/react-app/score)](https://jsr.io/@udibo/react-app) 5 | [![CI/CD](https://github.com/udibo/react-app/actions/workflows/main.yml/badge.svg)](https://github.com/udibo/react-app/actions/workflows/main.yml) 6 | [![codecov](https://codecov.io/gh/udibo/react-app/branch/main/graph/badge.svg?token=G5XCR01X8E)](https://codecov.io/gh/udibo/react-app) 7 | [![license](https://img.shields.io/github/license/udibo/react-app)](https://github.com/udibo/react-app/blob/main/LICENSE) 8 | 9 | ## Description 10 | 11 | Udibo React App is a [React](https://react.dev) framework for building 12 | full-stack web applications with [Deno](https://deno.com). 13 | 14 | On the frontend, it uses [React Router](https://reactrouter.com) to handle 15 | client side routing and 16 | [React Helmet Async](https://www.npmjs.com/package/react-helmet-async) to manage 17 | all of your changes to metadata for your website. Client side routing enables a 18 | faster user experience by fetching the new data it needs for the next page when 19 | navigating your site instead of having to fetch and re-render a whole new page. 20 | 21 | On the backend, it uses the [Oak](https://jsr.io/@oak/oak) middleware framework 22 | for handling HTTP requests. If you are coming from using Node.js, the Oak 23 | middleware framework is very similar to [Express](https://expressjs.com/) but 24 | leverages async functions instead of callbacks to provide a better developer 25 | experience. 26 | 27 | For bundling your user interface code for the browser, Udibo React App uses 28 | [esbuild](https://esbuild.github.io/). It can generate bundles for your 29 | application very quickly. Udibo React App's dev script enables automatic 30 | rebuilds and reloads of your application as you make changes to it, which makes 31 | it so you can see the effects of your changes quickly. 32 | 33 | Ontop of tying together those tools for the frontend and backend, Udibo React 34 | App provides file based routing for both the user interface and the API. Routes 35 | for the user interface will automatically be pre-rendered on the server by 36 | default, making your application load quickly. 37 | 38 | Whether you're a beginner or an experienced developer, the Udibo React App 39 | framework is a valuable resource for learning and building robust web 40 | applications. It provides a solid foundation for creating scalable and 41 | performant projects, enabling developers to deliver high-quality software 42 | solutions. 43 | 44 | ## Features 45 | 46 | - Supports TypeScript and JavaScript out of the box 47 | - File based routing like [Next.js](https://nextjs.org), 48 | [Remix](https://remix.run/) and [Fresh](https://fresh.deno.dev) for both your 49 | application's UI and API 50 | - Server side rendering 51 | - Easy to extend 52 | - Error boundaries that work both on the server and in the browser 53 | - Quick builds with hot reloading 54 | - Can run on the edge with [Deno Deploy](https://deno.com/deploy) 55 | 56 | ## Documentation 57 | 58 | The documentation for how to use all of the entrypoints for this framework's 59 | package can be found on JSR 60 | ([@udibo/react-app](https://jsr.io/@udibo/react-app/doc)). 61 | 62 | In addition to that documentation for the code below is a list of guides for how 63 | to use the framework. 64 | 65 | - [Getting Started](docs/getting-started.md) 66 | - [Configuration](docs/configuration.md) 67 | - [Development tools](docs/development-tools.md) 68 | - [Routing](docs/routing.md) 69 | - [HTTP Middleware](docs/http-middleware.md) 70 | - [Static Files](docs/static-files.md) 71 | - [Metadata](docs/metadata.md) 72 | - [Styling](docs/styling.md) 73 | - [State Management](docs/state-management.md) 74 | - [Forms](docs/forms.md) 75 | - [Error Handling](docs/error-handling.md) 76 | - [Testing](docs/testing.md) 77 | - [Logging](docs/logging.md) 78 | - [CI/CD](docs/ci-cd.md) 79 | 80 | ## Contributing 81 | 82 | To contribute, please read the [contributing instruction](CONTRIBUTING.md). 83 | -------------------------------------------------------------------------------- /build.test.ts: -------------------------------------------------------------------------------- 1 | import "./build.ts"; 2 | -------------------------------------------------------------------------------- /client.test.tsx: -------------------------------------------------------------------------------- 1 | import "./client.tsx"; 2 | -------------------------------------------------------------------------------- /client.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * This module is meant for internal use only. It contains functions used to render the application. 3 | * It is only expected to be imported from the `_main.tsx` file in the routes directory that is generated by the build script. 4 | * 5 | * @module 6 | */ 7 | /** @jsxRuntime automatic */ 8 | /** @jsxImportSource npm:react@18 */ 9 | /** @jsxImportSourceTypes npm:@types/react@18 */ 10 | import { lazy as reactLazy, startTransition, StrictMode } from "react"; 11 | import type { ComponentType, LazyExoticComponent } from "react"; 12 | import * as reactHelmetAsync from "react-helmet-async"; 13 | const reactHelmetAsyncFixed = reactHelmetAsync; 14 | const { HelmetProvider } = reactHelmetAsyncFixed.default ?? 15 | reactHelmetAsync; 16 | import { hydrateRoot } from "react-dom/client"; 17 | import { createBrowserRouter, RouterProvider } from "react-router-dom"; 18 | import type { RouteObject } from "react-router-dom"; 19 | 20 | import { type AppWindow, isDevelopment } from "./env.ts"; 21 | import { ErrorContext, InitialStateContext } from "./context.ts"; 22 | import { HttpError, withErrorBoundary } from "./error.tsx"; 23 | import type { ErrorBoundaryProps, FallbackProps } from "./error.tsx"; 24 | import { getLogger } from "./log.ts"; 25 | 26 | /** 27 | * An interface that defines the configuration for the application's hydration. 28 | */ 29 | export interface HydrateOptions { 30 | /** 31 | * A react router route object. 32 | * The build script will automatically generate this for your application's routes. 33 | * The route object is the default export from the generated `_main.tsx` file in your routes directory. 34 | */ 35 | route: RouteObject; 36 | } 37 | type AppOptions = HydrateOptions; 38 | 39 | /** 40 | * A React component used to render the application. 41 | * 42 | * @param options - The configuration for rendering the application. 43 | */ 44 | function App< 45 | SharedState extends Record = Record< 46 | string | number, 47 | unknown 48 | >, 49 | >(options: AppOptions) { 50 | const log = getLogger(); 51 | const { route } = options; 52 | const router = createBrowserRouter([route]); 53 | const rawError = (window as AppWindow).app?.error; 54 | const { stack, ...errorOptions } = rawError ?? {}; 55 | const error = rawError && new HttpError(errorOptions); 56 | if (error) { 57 | if (typeof stack === "string") { 58 | error.stack = stack; 59 | } 60 | log.error("Server error", error); 61 | } 62 | 63 | if (isDevelopment()) { 64 | const devPort = (window as AppWindow).app?.devPort ?? 9001; 65 | const source = new EventSource(`http://localhost:${devPort}/live-reload`); 66 | source.addEventListener("open", () => { 67 | log.info("Live reload: Waiting for change"); 68 | }); 69 | source.addEventListener("close", () => { 70 | log.info("Live reload: Stopped"); 71 | }); 72 | source.addEventListener("error", (event) => { 73 | log.error("Live reload: Error", event); 74 | }); 75 | source.addEventListener("reload", () => { 76 | log.info("Live reload: Reloading"); 77 | location.reload(); 78 | }); 79 | 80 | globalThis.addEventListener("beforeunload", () => source.close()); 81 | } 82 | 83 | const initialState = (window as AppWindow).app?.initialState ?? 84 | {} as SharedState; 85 | const appErrorContext = { error }; 86 | return ( 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | ); 97 | } 98 | 99 | /** 100 | * This function is used to hydrate the application in the browser. 101 | * It turns the server-rendered application into a single-page application (SPA). 102 | * Hydration is not required if only server-side rendering is desired. 103 | * 104 | * Example usage: 105 | * ```tsx 106 | * import { hydrate } from "@udibo/react-app"; 107 | * import route from "./routes/_main.tsx"; 108 | * 109 | * hydrate({ route }); 110 | * ``` 111 | * 112 | * @param options - The configuration for the application's hydration. 113 | * - `route`: A react router route object. The build script will automatically generate this for your application's routes. 114 | * The route object is the default export from the generated `_main.tsx` file in your routes directory. 115 | */ 116 | export function hydrate< 117 | SharedState extends Record = Record< 118 | string | number, 119 | unknown 120 | >, 121 | >(options: HydrateOptions): void { 122 | const { route } = options; 123 | const hydrate = () => 124 | startTransition(() => { 125 | hydrateRoot( 126 | document.getElementById("root"), 127 | route={route} />, 128 | ); 129 | }); 130 | 131 | if (typeof requestIdleCallback !== "undefined") { 132 | requestIdleCallback(hydrate); 133 | } else { 134 | // Safari doesn't support requestIdleCallback 135 | // https://caniuse.com/requestidlecallback 136 | setTimeout(hydrate, 1); 137 | } 138 | } 139 | 140 | /** 141 | * An interface that defines a route file. 142 | * A route file exports a react component by default. 143 | * It can optionally export an `ErrorFallback` or `boundary` that will be used for an `ErrorBoundary` around the react component that it exports by default. 144 | */ 145 | export type RouteFile = { 146 | /** The react component for the route. */ 147 | default: ComponentType; 148 | /** An ErrorFallback for an ErrorBoundary around the react component for the route. */ 149 | ErrorFallback?: ComponentType; 150 | /** The boundary used by the route. If there is an ErrorFallback exported, exporting a boundary will override the default boundary. */ 151 | boundary?: string; 152 | }; 153 | 154 | /** 155 | * A function used to lazily load a component. This function takes in a factory that returns a Promise that resolves to a React component. 156 | * The purpose of this function is to automatically add error boundaries to routes with an `ErrorFallback` or `boundary` export. 157 | * This function is intended for internal use only, and is typically used in the generated `_main.tsx` file for a routes directory. 158 | * 159 | * @param boundary - The boundary used by the route being imported. 160 | * @param factory - A factory that returns a Promise that resolves to a React component. 161 | * @returns A React component that is lazily loaded. 162 | */ 163 | export function lazy< 164 | T extends RouteFile, 165 | >(factory: () => Promise): LazyExoticComponent; 166 | export function lazy< 167 | T extends RouteFile, 168 | >( 169 | boundary: string, 170 | factory: () => Promise, 171 | ): LazyExoticComponent; 172 | export function lazy< 173 | T extends RouteFile, 174 | >( 175 | boundaryOrFactory?: string | (() => Promise), 176 | factory?: () => Promise, 177 | ): LazyExoticComponent { 178 | let boundary = typeof boundaryOrFactory === "string" 179 | ? boundaryOrFactory 180 | : undefined; 181 | if (typeof boundaryOrFactory !== "string") factory = boundaryOrFactory; 182 | return reactLazy(async () => { 183 | try { 184 | const { default: Component, ErrorFallback, boundary: boundaryOverride } = 185 | await factory!(); 186 | const errorBoundaryProps = { 187 | FallbackComponent: ErrorFallback, 188 | } as ErrorBoundaryProps; 189 | if (boundaryOverride) boundary = boundaryOverride; 190 | if (boundary) errorBoundaryProps.boundary = boundaryOverride ?? boundary; 191 | 192 | return { 193 | default: errorBoundaryProps.FallbackComponent 194 | ? withErrorBoundary(Component, errorBoundaryProps) 195 | : Component, 196 | }; 197 | } catch (error) { 198 | const log = getLogger(); 199 | log.error("Error loading component", error, { boundary }); 200 | window.location.reload(); 201 | throw error; 202 | } 203 | }); 204 | } 205 | -------------------------------------------------------------------------------- /context.ts: -------------------------------------------------------------------------------- 1 | import { type Context, createContext } from "react"; 2 | import type { HttpError } from "@udibo/http-error"; 3 | 4 | /** 5 | * A context object that is used to provide errors on the server to the browser. 6 | * It takes an object with an optional `error` property, which represents the HttpError that occurred. 7 | * This context is intended for internal use and testing only. 8 | */ 9 | export const ErrorContext: Context<{ error?: HttpError }> = createContext< 10 | { error?: HttpError } 11 | >({}); 12 | 13 | /** 14 | * A context object that is used to provide the initial state of the application to the browser. 15 | * This context is intended for internal use and testing only. 16 | */ 17 | export const InitialStateContext: Context = createContext({}); 18 | -------------------------------------------------------------------------------- /deno.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@udibo/react-app", 3 | "version": "0.24.3", 4 | "exports": { 5 | ".": "./mod.tsx", 6 | "./build": "./build.ts", 7 | "./dev": "./dev.ts", 8 | "./client": "./client.tsx", 9 | "./server": "./server.tsx", 10 | "./test-utils": "./test-utils.ts", 11 | "./global-jsdom": "./global-jsdom.ts" 12 | }, 13 | "publish": { 14 | "include": [ 15 | "LICENSE", 16 | "**/*.json", 17 | "**/*.jsonc", 18 | "**/*.md", 19 | "**/*.ts", 20 | "**/*.tsx" 21 | ], 22 | "exclude": [ 23 | "**/*.test.ts", 24 | "**/*.test.tsx", 25 | "test-utils.tsx", 26 | "example", 27 | "coverage", 28 | "node_modules" 29 | ] 30 | }, 31 | "tasks": { 32 | // Builds the application. 33 | "build": "cd ./example && deno run -A ./build.ts", 34 | // Builds the application in development mode. 35 | "build-dev": "export APP_ENV=development NODE_ENV=development && deno task build", 36 | // Builds the application in production mode. 37 | "build-prod": "export APP_ENV=production NODE_ENV=production && deno task build", 38 | // Builds and runs the application in development mode, with hot reloading. 39 | "dev": "export APP_ENV=development NODE_ENV=development && cd ./example && deno run -A ./dev.ts", 40 | // Runs the application. Requires the application to be built first. 41 | "run": "cd ./example && deno run -A ./main.ts", 42 | // Runs the application in development mode. Requires the application to be built first. 43 | "run-dev": "export APP_ENV=development NODE_ENV=development && deno task run", 44 | // Runs the application in production mode. Requires the application to be built first. 45 | "run-prod": "export APP_ENV=production NODE_ENV=production && deno task run", 46 | // Runs the tests. 47 | "test": "export APP_ENV=test NODE_ENV=development && deno test -A --trace-leaks", 48 | // Runs the tests in watch mode. 49 | "test-watch": "deno task test --watch", 50 | // Checks the formatting and runs the linter. 51 | "check": "deno lint && deno fmt --check", 52 | // Gets your branch up to date with master after a squash merge. 53 | "git-rebase": "git fetch origin main && git rebase --onto origin/main HEAD" 54 | }, 55 | "compilerOptions": { 56 | "lib": ["esnext", "dom", "dom.iterable", "dom.asynciterable", "deno.ns"], 57 | "jsx": "react-jsx", 58 | "jsxImportSource": "react", 59 | "jsxImportSourceTypes": "@types/react" 60 | }, 61 | "nodeModulesDir": true, 62 | "exclude": [ 63 | "coverage", 64 | "node_modules", 65 | "example/public/build", 66 | "example/routes/_main.ts", 67 | "example/routes/_main.tsx" 68 | ], 69 | "imports": { 70 | "@udibo/http-error": "jsr:@udibo/http-error@0", 71 | "@udibo/react-app": "./mod.tsx", 72 | "@udibo/react-app/build": "./build.ts", 73 | "@udibo/react-app/dev": "./dev.ts", 74 | "@udibo/react-app/server": "./server.tsx", 75 | "@udibo/react-app/client": "./client.tsx", 76 | "@udibo/react-app/test-utils": "./test-utils.tsx", 77 | "@udibo/react-app/global-jsdom": "./global-jsdom.ts", 78 | "@luca/esbuild-deno-loader": "jsr:@luca/esbuild-deno-loader@0.10", 79 | "@oak/oak": "jsr:@oak/oak@16", 80 | "@std/assert": "jsr:@std/assert@1", 81 | "@std/async": "jsr:@std/async@1", 82 | "@std/fs": "jsr:@std/fs@1", 83 | "@std/log": "jsr:@std/log@0", 84 | "@std/path": "jsr:@std/path@1", 85 | "@std/testing": "jsr:@std/testing@1", 86 | "esbuild": "npm:esbuild@0.23", 87 | "react": "npm:react@18", 88 | "@types/react": "npm:@types/react@18", 89 | "react-dom": "npm:react-dom@18", 90 | "react-error-boundary": "npm:react-error-boundary@4", 91 | "react-router-dom": "npm:react-router-dom@6", 92 | "react-helmet-async": "npm:react-helmet-async@2", 93 | "serialize-javascript": "npm:serialize-javascript@6", 94 | "@testing-library/react": "npm:@testing-library/react@16", 95 | "global-jsdom": "npm:global-jsdom@24", 96 | "@tanstack/react-query": "npm:@tanstack/react-query@5" 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /dev.test.ts: -------------------------------------------------------------------------------- 1 | import "./dev.ts"; 2 | -------------------------------------------------------------------------------- /dev.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This module provides a function for starting a development server that watches for changes in the file system. 3 | * It will automatically rebuild the application and restart the server when changes are detected. 4 | * The server will also notify any active browser sessions to reload the page once the new build is ready. 5 | * 6 | * If the default configuration settings work for building your application, you can run this script directly. 7 | * To call it directly, add the following to your deno config file's tasks section: 8 | * ```jsonc 9 | * "tasks": { 10 | // Builds and runs the application in development mode, with hot reloading. 11 | * "dev": "export APP_ENV=development NODE_ENV=development && deno run -A --config=deno.jsonc jsr:@udibo/react-app@0.24.2/dev", 12 | * } 13 | * ``` 14 | * 15 | * Note: The `NODE_ENV` environment variable is set because some libraries like react use it to determine the environment. 16 | * Calling it directly also requires you to specify the config file to use along with using a jsr specifier. 17 | * 18 | * If the default configuration settings are insufficient for your application, you can create a custom dev script like shown below: 19 | * ```ts 20 | * import { startDev } from "@udibo/react-app/dev"; 21 | * import { logFormatter } from "@udibo/react-app"; 22 | * import * as log from "@std/log"; 23 | * 24 | * // Import the build options from the build script 25 | * import { buildOptions } from "./build.ts"; 26 | * 27 | * // You can enable dev script logging here or in a separate file that you import into this file. 28 | * const level = isDevelopment() ? "DEBUG" : "INFO"; 29 | * log.setup({ 30 | * handlers: { 31 | * default: new log.ConsoleHandler(level, { 32 | * formatter: logFormatter, 33 | * }), 34 | * }, 35 | * loggers: { "react-app": { level, handlers: ["default"] } }, 36 | * }); 37 | * 38 | * startDev({ 39 | * buildOptions, 40 | * // Add your own options here 41 | * }); 42 | * ``` 43 | * 44 | * Then update your deno config file's tasks section to use your dev script: 45 | * ```jsonc 46 | * "tasks": { 47 | // Builds and runs the application in development mode, with hot reloading. 48 | * "dev": "export APP_ENV=development NODE_ENV=development && deno run -A ./dev.ts", 49 | * } 50 | * ``` 51 | * 52 | * @module 53 | */ 54 | 55 | import * as path from "@std/path"; 56 | import { debounce } from "@std/async/debounce"; 57 | import * as log from "@std/log"; 58 | import { 59 | Application, 60 | Router, 61 | ServerSentEvent, 62 | type ServerSentEventTarget, 63 | } from "@oak/oak"; 64 | 65 | import { isDevelopment, isTest } from "./env.ts"; 66 | import { getLogger } from "./log.ts"; 67 | import { build, getBuildOptions, rebuild } from "./build.ts"; 68 | import type { BuildOptions } from "./build.ts"; 69 | import { logFormatter } from "./mod.tsx"; 70 | 71 | const sessions = new Map(); 72 | let nextSessionId = 0; 73 | 74 | function createDevApp() { 75 | const app = new Application(); 76 | const router = new Router() 77 | .get("/live-reload", async (context) => { 78 | context.response.headers.set( 79 | "Access-Control-Allow-Origin", 80 | `*`, 81 | ); 82 | const target = await context.sendEvents({ keepAlive: true }); 83 | 84 | const sessionId = nextSessionId++; 85 | target.addEventListener("close", () => { 86 | sessions.delete(sessionId); 87 | }); 88 | target.addEventListener("error", (event) => { 89 | if (sessions.has(sessionId)) { 90 | getLogger().error("Live reload: Error", event); 91 | console.log(event); 92 | } 93 | }); 94 | sessions.set(sessionId, target); 95 | target.dispatchMessage("Waiting"); 96 | }) 97 | .get("/listening", ({ response }) => { 98 | response.status = 200; 99 | 100 | if (reload) { 101 | getLogger().info("Server restarted"); 102 | reload = false; 103 | queueMicrotask(() => { 104 | for (const target of [...sessions.values()]) { 105 | target.dispatchEvent(new ServerSentEvent("reload", { data: null })); 106 | } 107 | }); 108 | } else { 109 | getLogger().info("Server started"); 110 | } 111 | }); 112 | 113 | app.use(router.routes(), router.allowedMethods()); 114 | 115 | app.addEventListener("error", ({ error }) => { 116 | console.error("Uncaught app error", error); 117 | }); 118 | 119 | app.addEventListener("listen", ({ hostname, port, secure }) => { 120 | const origin = `${secure ? "https://" : "http://"}${hostname}`; 121 | getLogger().info(`Live reload listening on: ${origin}:${port}`); 122 | }); 123 | 124 | return app; 125 | } 126 | 127 | let runProcess: Deno.ChildProcess | null = null; 128 | function runDev(entryPoint: string) { 129 | const runCommand = new Deno.Command(Deno.execPath(), { 130 | args: ["run", "-A", entryPoint], 131 | stdin: "null", 132 | }); 133 | runProcess = runCommand.spawn(); 134 | } 135 | 136 | let buildInit = false; 137 | let building = false; 138 | let buildAgain = false; 139 | let buildAgainFor = ""; 140 | let restarting = false; 141 | let restartAgain = false; 142 | let reload = false; 143 | 144 | async function buildDev( 145 | entryPoint: string, 146 | buildOptions?: BuildOptions, 147 | changedPath?: string, 148 | ) { 149 | if (building) { 150 | buildAgain = true; 151 | buildAgainFor = changedPath ?? ""; 152 | } else { 153 | buildAgain = false; 154 | buildAgainFor = ""; 155 | restartAgain = false; 156 | reload = false; 157 | building = true; 158 | 159 | if (changedPath) { 160 | getLogger().info(`Detected change: ${changedPath}`); 161 | } 162 | 163 | let success = false; 164 | try { 165 | if (!buildInit) { 166 | buildInit = true; 167 | success = await build(buildOptions); 168 | } else { 169 | success = await rebuild(); 170 | } 171 | } finally { 172 | building = false; 173 | if (buildAgain) { 174 | await buildDev(entryPoint, buildOptions, buildAgainFor); 175 | } else if (success && runProcess) { 176 | await restartApp(entryPoint); 177 | } 178 | } 179 | } 180 | } 181 | 182 | async function restartApp(entryPoint: string) { 183 | if (restarting) { 184 | restartAgain = true; 185 | } else if (runProcess) { 186 | restartAgain = false; 187 | reload = false; 188 | restarting = true; 189 | getLogger().info("Restarting app"); 190 | queueMicrotask(() => { 191 | try { 192 | runProcess!.kill(); 193 | } catch { 194 | // Ignore error 195 | } 196 | }); 197 | try { 198 | await runProcess.status; 199 | } catch { 200 | // Ignore error 201 | } 202 | queueMicrotask(async () => { 203 | runDev(entryPoint); 204 | restarting = false; 205 | if (restartAgain) { 206 | await restartApp(entryPoint); 207 | } else if (!building) { 208 | reload = true; 209 | } 210 | }); 211 | } 212 | } 213 | 214 | /** The options for starting the dev script. */ 215 | export interface DevOptions { 216 | /** 217 | * Used to identify and ignore build artifacts that should be ignored by the live reload script. 218 | * This should be used to ignore files that are generated by the build script. 219 | * If a build artifact is not ignored, the live reload script will trigger a rebuild and restart of the app repeatedly. 220 | * The _main.ts and _main.tsx files in your routes directory are automatically ignored. 221 | */ 222 | isBuildArtifact?: (pathname: string) => boolean; 223 | /** The port for the dev script's live reload server. Defaults to 9001. */ 224 | devPort?: number; 225 | /** The entry point for the application server. Defaults to "./main.ts". */ 226 | entryPoint?: string; 227 | /** The options used for building the application. */ 228 | buildOptions?: BuildOptions; 229 | } 230 | 231 | /** 232 | * Starts a file watcher for triggering new builds to be generated. 233 | * When changes are made, the app will be re-built and the app will be restarted. 234 | * Any active browser sessions will be reloaded once the new build is ready and the app has been restarted. 235 | * 236 | * This function can be used in a dev script like the following: 237 | * ```ts 238 | * import { startDev } from "@udibo/react-app/dev"; 239 | * import * as log from "@std/log"; 240 | * 241 | * // Import the build options from the build script 242 | * import { buildOptions } from "./build.ts"; 243 | * 244 | * // You can enable dev script logging here or in a separate file that you import into this file. 245 | * log.setup({ 246 | * loggers: { "react-app": { level: "INFO", handlers: ["default"] } }, 247 | * }); 248 | * 249 | * startDev({ 250 | * buildOptions, 251 | * // Add your own options here 252 | * }); 253 | * ``` 254 | */ 255 | export function startDev(options: DevOptions = {}): void { 256 | const devPort = options.devPort ?? 9001; 257 | const entryPoint = options.entryPoint ?? "./main.ts"; 258 | const { 259 | isBuildArtifact: isCustomBuildArtifact, 260 | } = options; 261 | const buildOptions = getBuildOptions(options.buildOptions); 262 | 263 | const { workingDirectory, routesUrl, publicUrl } = buildOptions; 264 | const buildDir = path.resolve( 265 | publicUrl, 266 | `./${isTest() ? "test-" : ""}build`, 267 | ); 268 | const artifacts = new Set(); 269 | artifacts.add(path.resolve(routesUrl, "./_main.tsx")); 270 | artifacts.add(path.resolve(routesUrl, "./_main.ts")); 271 | 272 | function isBuildArtifact(pathname: string) { 273 | return pathname.startsWith(buildDir) || artifacts.has(pathname) || 274 | pathname.endsWith("/node_modules/.deno/.deno.lock.poll"); 275 | } 276 | 277 | const shouldBuild = isCustomBuildArtifact 278 | ? ((pathname: string) => 279 | !isBuildArtifact(pathname) && !isCustomBuildArtifact(pathname)) 280 | : ((pathname: string) => !isBuildArtifact(pathname)); 281 | 282 | queueMicrotask(async () => { 283 | await buildDev(entryPoint!, buildOptions); 284 | getLogger().info("Starting app"); 285 | queueMicrotask(() => runDev(entryPoint!)); 286 | }); 287 | 288 | async function watcher() { 289 | getLogger().info(`Watching ${workingDirectory}`); 290 | const build = debounce( 291 | (changedPath: string) => 292 | queueMicrotask(() => buildDev(entryPoint!, buildOptions, changedPath)), 293 | 20, 294 | ); 295 | for await (const event of Deno.watchFs(Deno.cwd())) { 296 | if (event.kind === "modify") { 297 | const path = event.paths.find(shouldBuild); 298 | if (path) build(path); 299 | } 300 | } 301 | } 302 | queueMicrotask(watcher); 303 | 304 | queueMicrotask(() => { 305 | const app = createDevApp(); 306 | app.listen({ port: devPort }); 307 | }); 308 | } 309 | 310 | if (import.meta.main) { 311 | const level = isDevelopment() ? "DEBUG" : "INFO"; 312 | log.setup({ 313 | handlers: { 314 | default: new log.ConsoleHandler(level, { 315 | formatter: logFormatter, 316 | }), 317 | }, 318 | loggers: { "react-app": { level, handlers: ["default"] } }, 319 | }); 320 | 321 | startDev(); 322 | } 323 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Documentation 2 | 3 | The documentation for how to use all of the entrypoints for this framework's 4 | package can be found on JSR 5 | ([@udibo/react-app](https://jsr.io/@udibo/react-app/doc)). 6 | 7 | In addition to that documentation for the code below is a list of guides for how 8 | to use the framework. 9 | 10 | - [Getting Started](getting-started.md) 11 | - [Configuration](configuration.md) 12 | - [Development tools](development-tools.md) 13 | - [Routing](routing.md) 14 | - [HTTP Middleware](http-middleware.md) 15 | - [Static Files](static-files.md) 16 | - [Metadata](metadata.md) 17 | - [Styling](styling.md) 18 | - [State Management](state-management.md) 19 | - [Forms](forms.md) 20 | - [Error Handling](error-handling.md) 21 | - [Testing](testing.md) 22 | - [Logging](logging.md) 23 | - [CI/CD](ci-cd.md) 24 | -------------------------------------------------------------------------------- /docs/ci-cd.md: -------------------------------------------------------------------------------- 1 | # CI/CD 2 | 3 | Continuous integration and continuous deployment is a process for developing 4 | apps faster, safer, and more efficiently. It is the automation of the manual, 5 | repetitive, and error prone tasks involved in integrating and deploying changes. 6 | The [ci](#ci) section covers building and testing your application. The 7 | [CD](#cd) section covers automating deploying your changes. 8 | 9 | - [CI/CD](#cicd) 10 | - [CI](#ci) 11 | - [GitHub actions](#github-actions) 12 | - [CD](#cd) 13 | - [GitHub actions](#github-actions-1) 14 | - [Deno Deploy](#deno-deploy) 15 | - [AWS](#aws) 16 | 17 | ## CI 18 | 19 | Continuous integration is all about building and testing your application to 20 | ensure changes made will work correctly if merged into the codebase. It will 21 | help you identify any conflicts or error caused by changes. This can be used to 22 | show the results of testing a change. It also helps developers identify and 23 | correct issues earlier. 24 | 25 | GitHub actions is one of the environments where this automation can exist. Other 26 | code hosting platforms have similar offerings. While this document doesn't cover 27 | all of them, you can develop it based on the steps described in this section. 28 | 29 | ### GitHub actions 30 | 31 | TODO: Go over using our CI workflow and what our CI workflow example is doing. 32 | Describe how they can take and modify our CI workflow if it doesn't meet their 33 | needs. 34 | 35 | ## CD 36 | 37 | Continuous deployment is about building and deploying your application after a 38 | change has passed CI. It can involve deploying to both staging and production 39 | environments. Automating deployment can help release software updates to 40 | customers as soon as they have been validated. 41 | 42 | GitHub actions is one of the environments where this automation can exist. Other 43 | code hosting platforms have similar offerings. While this document doesn't cover 44 | all of them, you can develop it based on the steps described in this section. 45 | 46 | ### GitHub actions 47 | 48 | TODO: Go over using our CD workflow and what our CD workflow example is doing. 49 | Describe how they can take and modify our CD workflow if it doesn't meet their 50 | needs. 51 | 52 | #### Deno Deploy 53 | 54 | TODO: Write instructions on this. Link back to Deno Deploy documentation. 55 | 56 | #### AWS 57 | 58 | TODO: Describe how to do this with docker. 59 | -------------------------------------------------------------------------------- /docs/configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | This guide covers the configuration options available for your project. 4 | 5 | - [Configuration](#configuration) 6 | - [Tasks](#tasks) 7 | - [Compiler options](#compiler-options) 8 | - [Formatting and linting](#formatting-and-linting) 9 | - [Imports](#imports) 10 | - [Server](#server) 11 | - [Build](#build) 12 | - [esbuild](#esbuild) 13 | - [Development](#development) 14 | - [Environment variables](#environment-variables) 15 | 16 | ## Tasks 17 | 18 | To learn more about using the default tasks, see the 19 | [tasks](getting-started.md#tasks) section in the getting started guide. Each 20 | task has a description of what it does in a comment above the task declaration. 21 | 22 | If you need to customize your build options to be different from the default, 23 | follow the instructions for adding the [build.ts](getting-started.md#buildts) 24 | and [dev.ts](getting-started.md#devts) files in the getting started guide. Then 25 | for more information on the build configuration options available, see the 26 | [build](#build) section of this guide. 27 | 28 | You can add any tasks that you want to your configuration, for more information 29 | on how to do so, see Deno's 30 | [task runner](https://docs.deno.com/runtime/manual/tools/task_runner/) guide. 31 | 32 | If you remove or rename any of the default tasks and you make use of our GitHub 33 | workflows, you may need to modify them to use different tasks. See the 34 | [CI/CD](ci-cd.md) guide for more information. 35 | 36 | ## Compiler options 37 | 38 | The default compiler options from the 39 | [getting started guide](getting-started.md#denojsonc) should be sufficient for 40 | most use cases. If you need to customize them, you can do so by modifying the 41 | `compilerOptions` in the `deno.jsonc` file. 42 | 43 | ```json 44 | { 45 | "compilerOptions": { 46 | "lib": ["esnext", "dom", "dom.iterable", "dom.asynciterable", "deno.ns"], 47 | "jsx": "react-jsx", 48 | "jsxImportSource": "react", 49 | "jsxImportSourceTypes": "@types/react" 50 | } 51 | } 52 | ``` 53 | 54 | For more information about the available options, see Deno's 55 | [configuring TypeScript in Deno guide](https://docs.deno.com/runtime/manual/advanced/typescript/configuration/). 56 | 57 | ## Formatting and linting 58 | 59 | You can configure Deno's formatter and linter to include or ignore files or 60 | directories by adding the fmt or lint key to your configuration. Alternatively, 61 | if you only want to exclude the same files or directories for both, you can 62 | update the top level excludes array. Below is the default excludes array from 63 | the [getting started guide](getting-started.md#denojsonc). It ensures that the 64 | formatter ignores coverage report json files, your npm dependencies stored in 65 | the node_modules directory, and the build artifacts from this framework. 66 | 67 | ```json 68 | { 69 | "exclude": [ 70 | "coverage", 71 | "node_modules", 72 | "public/build", 73 | "routes/_main.ts", 74 | "routes/_main.tsx" 75 | ] 76 | } 77 | ``` 78 | 79 | ## Imports 80 | 81 | The imports section of the `deno.jsonc` file is used to configure an import map 82 | for resolving bare specifiers. It makes it so that you don't have to specify the 83 | version everywhere that your dependency is used and provides one centralized 84 | place for updating those versions. 85 | 86 | For example, if your import map has the entry `"react": "npm:react@18.3.1"`, 87 | you'll be able to import react like `import React from "react"` and it will 88 | resolve to `npm:react@18.3.1`. 89 | 90 | The default import map from the 91 | [getting started guide](getting-started.md#denojsonc) also has 2 entries in it 92 | that make it easy to import files relative to the root of your project. Instead 93 | of having to import files with a path relative to the current file, you can 94 | import them with a path relative to the root of your project. For example, if 95 | you have your shared components in the components directory, you can import them 96 | like `import Button from "/components/Button.tsx"` instead of 97 | `import Button from "../../components/Button.tsx"`. 98 | 99 | ```json 100 | { 101 | "imports": { 102 | "/": "./", 103 | "./": "./" 104 | } 105 | } 106 | ``` 107 | 108 | For more information about import maps, see Deno's 109 | [import map](https://docs.deno.com/runtime/manual/basics/import_maps/) 110 | documentation. 111 | 112 | ## Server 113 | 114 | In all of the examples, the main entry point for the application is the 115 | `main.ts` or `main.tsx` file. This is the file that is used to start the 116 | application and contains the configuration for starting it. For most 117 | applications, you can just use the serve function to start your application. If 118 | a port is not specified, the operating system will choose an available port 119 | automatically. The route and router options come from geenerated build 120 | artifacts. The working directory is the directory that contains the main entry 121 | point file. The configuration for logging is stored in the `log.ts` file, for 122 | more information about configuring logging, see the [logging](logging.md) guide. 123 | 124 | ```ts 125 | import * as path from "@std/path"; 126 | import { serve } from "@udibo/react-app/server"; 127 | 128 | import route from "./routes/_main.tsx"; 129 | import router from "./routes/_main.ts"; 130 | import "./log.ts"; 131 | 132 | await serve({ 133 | port: 9000, 134 | router, 135 | route, 136 | workingDirectory: path.dirname(path.fromFileUrl(import.meta.url)), 137 | }); 138 | ``` 139 | 140 | ## Build 141 | 142 | TODO: Include link to esbuild plugins. Link to our styling guide for examples of 143 | using common styling plugins for esbuild. 144 | 145 | ### esbuild 146 | 147 | ## Development 148 | 149 | ## Environment variables 150 | 151 | TODO: Cover the basics of environment variables, with a focus on how to use 152 | dotfiles for development, production, and test environment variables. Update 153 | tasks to use dotfiles. 154 | -------------------------------------------------------------------------------- /docs/development-tools.md: -------------------------------------------------------------------------------- 1 | # Development tools 2 | 3 | TODO: Make an outline then fill it in with details. 4 | 5 | - [Development tools](#development-tools) 6 | - [VS Code](#vs-code) 7 | - [Configuration](#configuration) 8 | - [Debugging](#debugging) 9 | - [Docker](#docker) 10 | 11 | ## VS Code 12 | 13 | ### Configuration 14 | 15 | TODO: Cover how to configure VS Code. 16 | 17 | ### Debugging 18 | 19 | TODO: Explain how to use the debugger 20 | 21 | ## Docker 22 | -------------------------------------------------------------------------------- /docs/error-handling.md: -------------------------------------------------------------------------------- 1 | # Error handling 2 | 3 | - [Error handling](#error-handling) 4 | - [UI](#ui) 5 | - [Adding error boundaries](#adding-error-boundaries) 6 | - [Exporting an ErrorFallback component](#exporting-an-errorfallback-component) 7 | - [Manually adding an error boundary](#manually-adding-an-error-boundary) 8 | - [SSR](#ssr) 9 | - [API](#api) 10 | - [Default Error Handling](#default-error-handling) 11 | - [Creating and Throwing HttpErrors](#creating-and-throwing-httperrors) 12 | - [Controlling Error Exposure](#controlling-error-exposure) 13 | - [Adding Additional Error Data](#adding-additional-error-data) 14 | - [Overriding Default Error Handling](#overriding-default-error-handling) 15 | 16 | ## UI 17 | 18 | For UI routes, error handling typically involves using error boundaries to catch 19 | and display errors in a user-friendly manner, preventing the entire application 20 | from crashing due to a single component error. 21 | 22 | ### Adding error boundaries 23 | 24 | There are two main ways to add error handling to UI routes, either by exporting 25 | an `ErrorFallback` component from the route file or by manually adding an error 26 | boundary to your component. 27 | 28 | #### Exporting an ErrorFallback component 29 | 30 | The simplest way to add error handling to a UI route is to export an 31 | `ErrorFallback` component from the route file. The framework will automatically 32 | wrap the route's default export with an error boundary using this fallback. 33 | 34 | Example: 35 | 36 | ```tsx 37 | import { FallbackProps, HttpError } from "@udibo/react-app"; 38 | 39 | export default function BlogPost() { 40 | // ... component logic 41 | } 42 | 43 | export function ErrorFallback({ error }: FallbackProps) { 44 | return ( 45 |
46 |

Error

47 |

{error.message}

48 |
49 | ); 50 | } 51 | 52 | // Optionally, you can specify a custom boundary name 53 | export const boundary = "BlogPostErrorBoundary"; 54 | ``` 55 | 56 | In this example, if an error occurs within the `BlogPost` component, the 57 | `ErrorFallback` component will be rendered instead. 58 | 59 | #### Manually adding an error boundary 60 | 61 | For more control over error handling, you can manually add an error boundary to 62 | your component using the `ErrorBoundary` component or the `withErrorBoundary` 63 | higher-order component. 64 | 65 | Using `ErrorBoundary`: 66 | 67 | ```tsx 68 | import { DefaultErrorFallback, ErrorBoundary } from "@udibo/react-app"; 69 | 70 | export default function Blog() { 71 | return ( 72 | 76 | {/* Blog content */} 77 | 78 | ); 79 | } 80 | ``` 81 | 82 | Using `withErrorBoundary`: 83 | 84 | ```tsx 85 | import { DefaultErrorFallback, withErrorBoundary } from "@udibo/react-app"; 86 | 87 | function Blog() { 88 | // ... component logic 89 | } 90 | 91 | export default withErrorBoundary(Blog, { 92 | FallbackComponent: DefaultErrorFallback, 93 | boundary: "BlogErrorBoundary", 94 | }); 95 | ``` 96 | 97 | ### SSR 98 | 99 | In the browser, any errors that occur within a route will be caught by the 100 | nearest error boundary. When rendering on the server, the errors will have a 101 | boundary key added to them to indicate which error boundary they should be 102 | associated with during rendering. If a route throws an error, it will default to 103 | the nearest route's error boundary. 104 | 105 | In the following example, any errors thrown in the route will automatically have 106 | the boundary key set to the boundary for that route. If the route path is 107 | `/blog/:id` and the UI route file doesn't export a boundary constant, any errors 108 | thrown will have the `/blog/:id` boundary added to them. If the UI route file 109 | does export a boundary constant, that will be used instead of the default. 110 | 111 | ```ts 112 | import { HttpError } from "@udibo/react-app"; 113 | import { Router } from "@udibo/react-app/server"; 114 | 115 | import { getPost } from "../../services/posts.ts"; 116 | import type { PostsState } from "../../models/posts.ts"; 117 | 118 | export default new Router() 119 | .get("/", async (context) => { 120 | const { state, params } = context; 121 | const id = Number(params.id); 122 | if (isNaN(id) || Math.floor(id) !== id || id < 0) { 123 | throw new HttpError(400, "Invalid id"); 124 | } 125 | 126 | state.app.initialState.posts = { 127 | [id]: getPost(id), 128 | }; 129 | await state.app.render(); 130 | }); 131 | ``` 132 | 133 | Routes can have as many error boundaries as you need. If you want an error on 134 | the server to be caught by a specific boundary when doing server-side rendering, 135 | you'll need the error to be thrown with the boundary key set to the name of the 136 | boundary you want to catch the error. This can be done automatically by using 137 | the `errorBoundary` middleware. The following example shows how to use the 138 | `errorBoundary` middleware to catch errors in a route. Now instead of the errors 139 | having the routes boundary key added to them, it will have the 140 | `BlogErrorBoundary` boundary key added to them instead. 141 | 142 | ```ts 143 | import { HttpError } from "@udibo/react-app"; 144 | import { Router } from "@udibo/react-app/server"; 145 | 146 | import { getPost } from "../../services/posts.ts"; 147 | import type { PostsState } from "../../models/posts.ts"; 148 | 149 | export default new Router() 150 | .use(errorBoundary("BlogErrorBoundary")) 151 | .get("/", async (context) => { 152 | const { state, params } = context; 153 | const id = Number(params.id); 154 | if (isNaN(id) || Math.floor(id) !== id || id < 0) { 155 | throw new HttpError(400, "Invalid id"); 156 | } 157 | 158 | state.app.initialState.posts = { 159 | [id]: getPost(id), 160 | }; 161 | await state.app.render(); 162 | }); 163 | ``` 164 | 165 | Alternatively, you can throw an `HttpError` with the boundary key set to the 166 | name of the boundary you want to catch the error. In the following example, the 167 | invalid id error will be caught by the `BlogErrorBoundary` boundary instead of 168 | the default boundary. Any other errors will still be caught by the route's 169 | boundary. 170 | 171 | ```ts 172 | import { HttpError } from "@udibo/react-app"; 173 | import { Router } from "@udibo/react-app/server"; 174 | 175 | import { getPost } from "../../services/posts.ts"; 176 | import type { PostsState } from "../../models/posts.ts"; 177 | 178 | export default new Router() 179 | .get("/", async (context) => { 180 | const { state, params } = context; 181 | const id = Number(params.id); 182 | if (isNaN(id) || Math.floor(id) !== id || id < 0) { 183 | throw new HttpError(400, "Invalid id", { boundary: "BlogErrorBoundary" }); 184 | } 185 | 186 | state.app.initialState.posts = { 187 | [id]: getPost(id), 188 | }; 189 | await state.app.render(); 190 | }); 191 | ``` 192 | 193 | If the error could come from somewhere else that doesn't throw an HttpError with 194 | a boundary, you can catch and re-throw it as an HttpError with the correct 195 | boundary like shown in the following example. It is doing the same thing as the 196 | error boundary middleware, but only applying to the code within the try 197 | statement. Any errors thrown within there will have their boundary set to 198 | `BlogErrorBoundary`. Any errors thrown outside of there will have their boundary 199 | set to the route's boundary. 200 | 201 | ```ts 202 | import { HttpError } from "@udibo/react-app"; 203 | import { Router } from "@udibo/react-app/server"; 204 | 205 | import { getPost } from "../../services/posts.ts"; 206 | import type { PostsState } from "../../models/posts.ts"; 207 | 208 | export default new Router() 209 | .get("/", async (context) => { 210 | const { state, params } = context; 211 | const id = Number(params.id); 212 | if (isNaN(id) || Math.floor(id) !== id || id < 0) { 213 | throw new HttpError(400, "Invalid id"); 214 | } 215 | 216 | try { 217 | state.app.initialState.posts = { 218 | [id]: getPost(id), 219 | }; 220 | } catch (cause) { 221 | const error = HttpError.from<{ boundary?: string }>(cause); 222 | if (isDevelopment()) error.expose = true; 223 | error.data.boundary = "BlogErrorBoundary"; 224 | throw error; 225 | } 226 | await state.app.render(); 227 | }); 228 | ``` 229 | 230 | ## API 231 | 232 | In API routes, error handling involves catching and properly formatting errors, 233 | setting appropriate HTTP status codes, and potentially logging errors for 234 | debugging purposes. The framework provides utilities to streamline this process 235 | and ensure consistent error responses across your API. 236 | 237 | ### Default Error Handling 238 | 239 | By default, errors thrown in API routes are caught and handled automatically. 240 | The response body is set to an `ErrorResponse` object representing the error. 241 | This object typically includes: 242 | 243 | - `status`: The HTTP status code 244 | - `message`: A description of the error 245 | - `data`: Additional error details (if provided) 246 | 247 | These errors are also logged as API route errors, which can be useful for 248 | debugging and monitoring purposes. 249 | 250 | ### Creating and Throwing HttpErrors 251 | 252 | The framework provides an `HttpError` class that you can use to create and throw 253 | custom errors in your API routes. Here's how you can use it: 254 | 255 | ```typescript 256 | import { HttpError } from "@udibo/react-app"; 257 | 258 | // ... 259 | 260 | if (someErrorCondition) { 261 | throw new HttpError(400, "Invalid input"); 262 | } 263 | ``` 264 | 265 | #### Controlling Error Exposure 266 | 267 | You can use the `expose` property to control whether the error message is 268 | exposed to the client: 269 | 270 | ```typescript 271 | throw new HttpError(400, "Invalid input", { expose: false }); 272 | ``` 273 | 274 | When `expose` is set to `false`, the client will receive a generic error message 275 | instead of the specific one you provided. This is useful for hiding sensitive 276 | information or internal error details from users. HTTP errors with a status code 277 | of 500 or greater are not exposed to the client by default, they will only be 278 | exposed if you explicitly set `expose` to `true`. The inverse is true for HTTP 279 | errors with a status code between 400 and 499, they will be exposed to the 280 | client by default, but you can set `expose` to `false` to hide them. 281 | 282 | Non-HTTP errors will be converted into an HttpError with a status code of 500, 283 | with the original error being set as the cause. The cause will be logged but not 284 | exposed to the client. 285 | 286 | #### Adding Additional Error Data 287 | 288 | You can add extra context to your errors by including additional data: 289 | 290 | ```typescript 291 | throw new HttpError(400, "Form validation failed", { 292 | field: "email", 293 | reason: "Invalid format", 294 | }); 295 | ``` 296 | 297 | This additional data will be included in the `ErrorResponse` object sent to the 298 | client. It's important to note that this data is shared with the client, so be 299 | careful not to include any sensitive information. 300 | 301 | ### Overriding Default Error Handling 302 | 303 | If you need more control over error handling, you can override the default 304 | behavior by adding custom middleware to the root of your API routes. Here's an 305 | example of how to do this: 306 | 307 | ```ts 308 | import { Router } from "@udibo/react-app/server"; 309 | import { ErrorResponse, HttpError } from "@udibo/react-app"; 310 | import * as log from "@std/log"; 311 | 312 | export default new Router() 313 | .use(async ({ request, response }, next) => { 314 | try { 315 | await next(); 316 | } catch (cause) { 317 | const error = HttpError.from(cause); 318 | log.error("API Error", error); 319 | 320 | response.status = error.status; 321 | const extname = path.extname(request.url.pathname); 322 | if (error.status !== 404 || extname === "") { 323 | response.body = new ErrorResponse(error); 324 | } 325 | } 326 | }); 327 | ``` 328 | 329 | This middleware catches any errors thrown in subsequent middleware or route 330 | handlers. It converts the error to an `HttpError`, sets the appropriate status 331 | code, and formats the response body. You can customize this further to fit your 332 | specific error handling needs, such as integrating with error tracking services 333 | or applying different handling logic based on the error type. 334 | -------------------------------------------------------------------------------- /docs/forms.md: -------------------------------------------------------------------------------- 1 | # Forms 2 | 3 | TODO: Make an outline then fill it in with details. 4 | 5 | - [Forms](#forms) 6 | - [Basic](#basic) 7 | - [React Hook Forms](#react-hook-forms) 8 | 9 | ## Basic 10 | 11 | TODO: Cover using basic HTML forms and handling them with oak routes. 12 | 13 | ## React Hook Forms 14 | 15 | TODO: Explain the basics for how to use React Hook Forms. Link to their 16 | documentation. 17 | -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | This guide covers the basics of setting up, developing, and deploying an 4 | application using the Udibo React App framework. 5 | 6 | - [Getting Started](#getting-started) 7 | - [Setup](#setup) 8 | - [Copy example project](#copy-example-project) 9 | - [Manually create all the files](#manually-create-all-the-files) 10 | - [Required files](#required-files) 11 | - [deno.jsonc](#denojsonc) 12 | - [main.ts](#maints) 13 | - [log.ts](#logts) 14 | - [routes/main.ts](#routesmaints) 15 | - [routes/main.tsx](#routesmaintsx) 16 | - [routes/index.tsx](#routesindextsx) 17 | - [react.d.ts](#reactdts) 18 | - [Optional files](#optional-files) 19 | - [build.ts](#buildts) 20 | - [dev.ts](#devts) 21 | - [.gitignore](#gitignore) 22 | - [test-utils.tsx](#test-utilstsx) 23 | - [.github/\*](#github) 24 | - [.github/workflows/main.yml](#githubworkflowsmainyml) 25 | - [.github/codecov.yml](#githubcodecovyml) 26 | - [.vscode/\*](#vscode) 27 | - [.vscode/settings.json](#vscodesettingsjson) 28 | - [Tasks](#tasks) 29 | - [Routing](#routing) 30 | - [Error handling](#error-handling) 31 | - [Metadata](#metadata) 32 | - [Logging](#logging) 33 | - [Testing](#testing) 34 | - [Deployment](#deployment) 35 | 36 | ## Setup 37 | 38 | There are 2 options for how to get started, you can either 39 | [copy the example project](#copy-example-project) or 40 | [manually create all the required files](#manually-create-all-the-required-files). 41 | 42 | ### Copy example project 43 | 44 | For copying the example, you can use git clone or download it from the GitHub 45 | repo. Below is a link to the example. 46 | 47 | https://github.com/udibo/react-app-example 48 | 49 | If you go to that link and click the Code button, the dropdown includes the 50 | option to download it or the url for cloning it with git. 51 | 52 | If you have git installed, you can run the following command to clone it. 53 | 54 | ```sh 55 | git clone https://github.com/udibo/react-app-example.git 56 | ``` 57 | 58 | For more information on cloning a repository, see 59 | [this guide](https://docs.github.com/en/repositories/creating-and-managing-repositories/cloning-a-repository#about-cloning-a-repository) 60 | from GitHub. 61 | 62 | For more information about the files in that example, see the 63 | [required files](#required-files) and [optional files](#optional-files) 64 | sections. 65 | 66 | ### Manually create all the files 67 | 68 | To get started, you are going to need a folder for your project and to create a 69 | few files in that directory. The following 2 sections lists those files and 70 | explains their purpose. 71 | 72 | ## Required files 73 | 74 | - [deno.jsonc](#denojsonc): The configuration for deno and contains a set of 75 | shortcuts for doing tasks. 76 | - [main.ts](#maints): The main entrypoint for running the application. 77 | - [log.ts](#logts): The configuration for how logs are handled. 78 | - [routes/main.ts](#routesmaints): A wrapper around the server side of the 79 | application. 80 | - [routes/main.tsx](#routesmaintsx): A wrapper around the client side of the 81 | application. 82 | - [routes/index.tsx](#routesindextsx): The homepage for the application. 83 | - [react.d.ts](#reactdts): Type definitions for React to enable autocompletion 84 | and type checking. 85 | 86 | ### deno.jsonc 87 | 88 | This is the configuration for deno and contains a set of shortcuts for doing 89 | tasks. 90 | 91 | All of the tasks in this file can be used by typing `deno task [task]`. For 92 | example: `deno task dev` would build and run the application in development mode 93 | with hot reloading. All of the configuration options besides the tasks are 94 | required. For more information about the tasks, see the [tasks section](#tasks). 95 | 96 | The `nodeModulesDir` option is set to `true` in this configuration. This is 97 | necessary for compatibility with certain VS Code extensions, such as the 98 | TailwindCSS extension, and for tools like Playwright 99 | ([see this comment](https://github.com/denoland/deno/issues/16899#issuecomment-2307899834)). 100 | 101 | While Deno typically doesn't use a `node_modules` directory, enabling this 102 | option ensures better compatibility with tools and extensions that expect a 103 | Node.js-like environment. 104 | 105 | ```jsonc 106 | { 107 | "tasks": { 108 | // Builds the application. 109 | "build": "deno run -A --config=deno.jsonc jsr:@udibo/react-app@0.24.3/build", 110 | // Builds the application in development mode. 111 | "build-dev": "export APP_ENV=development NODE_ENV=development && deno task build", 112 | // Builds the application in production mode. 113 | "build-prod": "export APP_ENV=production NODE_ENV=production && deno task build", 114 | // Builds and runs the application in development mode, with hot reloading. 115 | "dev": "export APP_ENV=development NODE_ENV=development && deno run -A --config=deno.jsonc jsr:@udibo/react-app@0.24.3/dev", 116 | // Runs the application. Requires the application to be built first. 117 | "run": "deno run -A ./main.ts", 118 | // Runs the application in development mode. Requires the application to be built first. 119 | "run-dev": "export APP_ENV=development NODE_ENV=development && deno task run", 120 | // Runs the application in production mode. Requires the application to be built first. 121 | "run-prod": "export APP_ENV=production NODE_ENV=production && deno task run", 122 | // Runs the tests. 123 | "test": "export APP_ENV=test NODE_ENV=development && deno test -A --trace-leaks", 124 | // Runs the tests in watch mode. 125 | "test-watch": "export APP_ENV=test NODE_ENV=development && deno test -A --trace-leaks --watch", 126 | // Checks the formatting and runs the linter. 127 | "check": "deno lint && deno fmt --check", 128 | // Gets your branch up to date with master after a squash merge. 129 | "git-rebase": "git fetch origin main && git rebase --onto origin/main HEAD" 130 | }, 131 | "compilerOptions": { 132 | "lib": ["esnext", "dom", "dom.iterable", "dom.asynciterable", "deno.ns"], 133 | "jsx": "react-jsx", 134 | "jsxImportSource": "react", 135 | "jsxImportSourceTypes": "@types/react" 136 | }, 137 | "nodeModulesDir": true, 138 | "exclude": [ 139 | "coverage", 140 | "node_modules", 141 | "public/build", 142 | "routes/_main.ts", 143 | "routes/_main.tsx" 144 | ], 145 | "imports": { 146 | "/": "./", 147 | "./": "./", 148 | "@udibo/react-app": "jsr:@udibo/react-app@0.24.3", 149 | "@std/assert": "jsr:@std/assert@1", 150 | "@std/log": "jsr:@std/log@0", 151 | "@std/path": "jsr:@std/path@1", 152 | "@std/testing": "jsr:@std/testing@1", 153 | "react": "npm:react@18", 154 | "@types/react": "npm:@types/react@18", 155 | "react-router-dom": "npm:react-router-dom@6", 156 | "react-helmet-async": "npm:react-helmet-async@2", 157 | "@testing-library/react": "npm:@testing-library/react@16", 158 | "global-jsdom": "npm:global-jsdom@24" 159 | } 160 | } 161 | ``` 162 | 163 | ### main.ts 164 | 165 | The main entrypoint for running the application. 166 | 167 | The serve function starts the application on the specified port. This example 168 | uses port 9000 but you can use any port you want. Importing the "./log.ts" file 169 | ensures logging is done with your logging configuration specified in that file. 170 | The two _main files in the routes directory are the route and router generated 171 | by the build script. 172 | 173 | ```ts 174 | import * as path from "@std/path"; 175 | import { serve } from "@udibo/react-app/server"; 176 | 177 | import route from "./routes/_main.tsx"; 178 | import router from "./routes/_main.ts"; 179 | import "./log.ts"; 180 | 181 | await serve({ 182 | port: 9000, 183 | router, 184 | route, 185 | workingDirectory: path.dirname(path.fromFileUrl(import.meta.url)), 186 | }); 187 | ``` 188 | 189 | ### log.ts 190 | 191 | The configuration for how logs are handled. 192 | 193 | The react-app logger is used for logs made by the @udibo/react-app package. You 194 | can change the configuration however you'd like. The logFormatter is designed to 195 | handle the logs emitted by react-app. See the documentation for that function 196 | for more details about how it expects calls to be made to the logging functions 197 | and how those calls will translate into log messages. 198 | 199 | ```ts 200 | import * as log from "@std/log"; 201 | import { isDevelopment, isServer, logFormatter } from "@udibo/react-app"; 202 | 203 | const level = isDevelopment() ? "DEBUG" : "INFO"; 204 | log.setup({ 205 | handlers: { 206 | default: new log.ConsoleHandler(level, { 207 | formatter: logFormatter, 208 | useColors: isServer(), 209 | }), 210 | }, 211 | loggers: { "react-app": { level, handlers: ["default"] } }, 212 | }); 213 | ``` 214 | 215 | For more information, view our [logging guide](logging.md). 216 | 217 | ### routes/main.ts 218 | 219 | A wrapper around the server side of the application. 220 | 221 | This is where you should add middleware that you want to apply to all requests. 222 | In the following example, it adds middleware that will set the response time 223 | header and log information about the request. That middleware is not required, 224 | it is just an example of middleware. For more information about middleware, view 225 | our [HTTP middleware guide](http-middleware.md). 226 | 227 | Each subdirectory in the routes directory can have a `main.ts` file that applies 228 | middleware for all routes in that subdirectory. You can learn more about this in 229 | the [routing section](routing.md). 230 | 231 | ```ts 232 | import { Router } from "@udibo/react-app/server"; 233 | import * as log from "@std/log"; 234 | 235 | export default new Router() 236 | .use(async (context, next) => { 237 | const { request, response } = context; 238 | const start = Date.now(); 239 | try { 240 | await next(); 241 | } finally { 242 | const responseTime = Date.now() - start; 243 | response.headers.set("X-Response-Time", `${responseTime}ms`); 244 | log.info( 245 | `${request.method} ${request.url.href}`, 246 | { status: response.status, responseTime }, 247 | ); 248 | } 249 | }); 250 | ``` 251 | 252 | ### routes/main.tsx 253 | 254 | A wrapper around the client side of the application. 255 | 256 | This is a good place to define the layout for your website along with default 257 | metadata for all pages. It's a good place to add providers for context shared by 258 | your entire application. For example, theming context or information about the 259 | current session. If your server has context you would like relayed to the 260 | client, you can use the `useInitialState` function to access it. 261 | 262 | Each subdirectory in the routes directory can have a `main.tsx` file that wraps 263 | the entire route path. You can learn more about this in the [routing](#routing) 264 | section. 265 | 266 | ```tsx 267 | import { Suspense } from "react"; 268 | import { Link, Outlet } from "npm:react-router-dom@6"; 269 | import { DefaultErrorFallback, ErrorBoundary, Helmet } from "@udibo/react-app"; 270 | import "../log.ts"; 271 | 272 | import { Loading } from "../components/loading.tsx"; 273 | 274 | const navLinks = [ 275 | { label: "Home", to: "/" }, 276 | { label: "About", to: "/about" }, 277 | { label: "Blog", to: "/blog" }, 278 | { label: "Fake", to: "/fake" }, 279 | ]; 280 | 281 | export default function Main() { 282 | return ( 283 | <> 284 | 289 | 290 | 291 | 292 |
    293 | {navLinks.map((link) => ( 294 |
  • 295 | {link.label} 296 |
  • 297 | ))} 298 |
299 | }> 300 | 301 | 302 | 303 | 304 | 305 | ); 306 | } 307 | ``` 308 | 309 | The ErrorBoundary around the Outlet will create an error boundary for the entire 310 | application, if an error is thrown and not caught by another error boundary 311 | first, the fallback component will be shown. For more information, view our 312 | [error handling guide](error-handling.md). 313 | 314 | ### routes/index.tsx 315 | 316 | The homepage for the application. The contents of the component aren't required, 317 | just an example of a homepage. 318 | 319 | Each subdirectory in the routes directory can have an `index.tsx` file that 320 | represents the default view for that path. You can learn more about this in the 321 | [routing section](routing.md). 322 | 323 | ```tsx 324 | import { Helmet } from "@udibo/react-app"; 325 | 326 | export default function Index() { 327 | return ( 328 | <> 329 | 330 | Home 331 | 335 | 336 |

Home

337 |

This is a basic example of a Udibo React App.

338 | 346 | 347 | ); 348 | } 349 | ``` 350 | 351 | ### react.d.ts 352 | 353 | This file is required for Deno's LSP to recognize the types for React and to 354 | provide autocompletions. 355 | 356 | ```ts 357 | declare module "react" { 358 | // @ts-types="@types/react" 359 | import React from "npm:react@18"; 360 | export = React; 361 | } 362 | ``` 363 | 364 | ## Optional files 365 | 366 | - [build.ts](#buildts): Builds the application with your own build options. 367 | - [dev.ts](#devts): Starts a development server using your own build options. 368 | - [test-utils.tsx](#test-utilstsx): Provides additional utilities for testing 369 | your application. 370 | - [.gitignore](#gitignore): Contains a list of files that should not be 371 | committed. 372 | 373 | ### build.ts 374 | 375 | If the default build configuration settings are insufficient for your 376 | application, you can create a build script like shown below: 377 | 378 | ```ts 379 | import { buildOnce, type BuildOptions } from "@udibo/react-app/build"; 380 | import "./log.ts"; 381 | 382 | // export the buildOptions so that you can use them in your dev script. 383 | // You will need a dev script if you have non default build options. 384 | export const buildOptions: BuildOptions = { 385 | // Add your own build options here if the defaults are not sufficient. 386 | }; 387 | 388 | if (import.meta.main) { 389 | buildOnce(buildOptions); 390 | } 391 | ``` 392 | 393 | Then update your deno config file's tasks section to use your build script: 394 | 395 | ```jsonc 396 | "tasks": { 397 | // Builds the application. 398 | "build": "deno run -A ./build.ts", 399 | // Builds the application in development mode. 400 | "build-dev": "export APP_ENV=development NODE_ENV=development && deno task build", 401 | // Builds the application in production mode. 402 | "build-prod": "export APP_ENV=production NODE_ENV=production && deno task build", 403 | } 404 | ``` 405 | 406 | For more information, view our [configuration guide](configuration.md). 407 | 408 | ### dev.ts 409 | 410 | If the default build or dev configuration settings are insufficient for your 411 | application, you can create a custom dev script like shown below: 412 | 413 | ```ts 414 | import { startDev } from "@udibo/react-app/dev"; 415 | import "./log.ts"; 416 | 417 | // Import the build options from the build script 418 | import { buildOptions } from "./build.ts"; 419 | 420 | startDev({ 421 | buildOptions, 422 | // Add your own options here 423 | }); 424 | ``` 425 | 426 | Then update your deno config file's tasks section to use your dev script: 427 | 428 | ```jsonc 429 | "tasks": { 430 | // Builds and runs the application in development mode, with hot reloading. 431 | "dev": "export APP_ENV=development NODE_ENV=development && deno run -A ./dev.ts", 432 | } 433 | ``` 434 | 435 | For more information, view our [configuration guide](configuration.md). 436 | 437 | ### .gitignore 438 | 439 | Contains a list of files that should not be committed. We don't need to commit 440 | the build artifacts which are stored in the public directory or the router and 441 | route scripts in the routes directory. We also don't need to commit the coverage 442 | files. 443 | 444 | ``` 445 | # Build 446 | public/build 447 | public/test-build 448 | routes/_main.tsx 449 | routes/_main.ts 450 | 451 | # Coverage 452 | coverage 453 | 454 | # Node modules 455 | node_modules 456 | ``` 457 | 458 | ### test-utils.tsx 459 | 460 | Provides additional utilities for testing your application. This script sets up 461 | the global document object, then re-exports all of the tools from react testing 462 | library. It also overrides the current render function with one that is 463 | disposable, making it easier to cleanup the global document object at the end of 464 | each test. 465 | 466 | We recommend writing tests for both your user interface and API. Tests help 467 | ensure your application functions as it should and will alert you if changes 468 | break your application. 469 | 470 | ```tsx 471 | import "@udibo/react-app/global-jsdom"; 472 | import { 473 | cleanup, 474 | render as _render, 475 | type RenderOptions, 476 | } from "@testing-library/react"; 477 | export * from "@testing-library/react"; 478 | 479 | export function render( 480 | ui: React.ReactElement, 481 | options?: Omit, 482 | ): ReturnType & Disposable { 483 | const result = _render(ui, options); 484 | return { 485 | ...result, 486 | [Symbol.dispose]() { 487 | cleanup(); 488 | }, 489 | }; 490 | } 491 | ``` 492 | 493 | Below is an example of how to make use of this module in a test case. In this 494 | test case, it renders the loading component and tests that the text content of 495 | it matches what we expect it to. By `using` the screen returned by the render 496 | function call, the test will call the cleanup function before the test finishes. 497 | 498 | ```tsx 499 | import { assertEquals } from "@std/assert"; 500 | import { describe, it } from "@std/testing/bdd"; 501 | 502 | import { render } from "../test-utils.tsx"; 503 | 504 | import { Loading } from "./loading.tsx"; 505 | 506 | const loadingTests = describe("Loading component"); 507 | 508 | it(loadingTests, "renders loading message", () => { 509 | using screen = render(); 510 | assertEquals(screen.getByText("Loading...").textContent, "Loading..."); 511 | }); 512 | ``` 513 | 514 | If you'd prefer calling the cleanup function yourself, you would want to add an 515 | afterEach hook to your test suite that calls the cleanup function. 516 | 517 | ```tsx 518 | import { assertEquals } from "@std/assert"; 519 | import { describe, it } from "@std/testing/bdd"; 520 | 521 | import { cleanup, render } from "../test-utils.tsx"; 522 | 523 | import { Loading } from "./loading.tsx"; 524 | 525 | const loadingTests = describe({ 526 | name: "Loading component", 527 | afterEach() { 528 | cleanup(); 529 | }, 530 | }); 531 | 532 | it(loadingTests, "renders loading message", () => { 533 | const screen = render(); 534 | assertEquals(screen.getByText("Loading...").textContent, "Loading..."); 535 | }); 536 | ``` 537 | 538 | ### .github/* 539 | 540 | If you plan on using GitHub actions for CI/CD, you'll want some of the files in 541 | this directory. If the default build configuration works for you, the examples 542 | below. For more information, view our [CI/CD guide](ci-cd.md). 543 | 544 | #### .github/workflows/main.yml 545 | 546 | If you'd like a GitHub action that will test your code, upload coverage reports, 547 | and deploy your code to Deno Deploy, you can use the following workflow for 548 | that. If you are not going to upload your test coverage report to Codecov, just 549 | omit the secret. If you are not using deploy, you can look at the referenced 550 | script to see how to write a workflow for preparing a production build and 551 | uploading it. If your configuration is unique, you can write your own CI and CD 552 | workflows based on ours. Those can be found on GitHub in the `.github/workflows` 553 | folder. 554 | 555 | In this example, the CI step builds the application, checks the formatting, 556 | lints it, and runs the tests. The CD step builds the application for production, 557 | and deploys it to Deno Deploy. 558 | 559 | ```yml 560 | name: CI/CD 561 | on: 562 | pull_request: 563 | push: 564 | branches: 565 | - main 566 | jobs: 567 | ci: 568 | name: CI 569 | uses: udibo/react-app/.github/workflows/ci.yml@0.24.3 570 | secrets: 571 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 572 | cd: 573 | name: CD 574 | needs: ci 575 | uses: udibo/react-app/.github/workflows/deploy.yml@0.24.3 576 | with: 577 | project: udibo-react-app-example 578 | ``` 579 | 580 | #### .github/codecov.yml 581 | 582 | If you are using codecov to report your test coverage, this file is a good 583 | starting point for configuring it. It's recommended to ignore coverage for your 584 | build artifacts. 585 | 586 | ```yml 587 | comment: false 588 | codecov: 589 | require_ci_to_pass: true 590 | coverage: 591 | status: 592 | project: 593 | default: 594 | informational: true 595 | ignore: 596 | - "public/build/**/*" 597 | ``` 598 | 599 | ### .vscode/* 600 | 601 | There is just one file required here if you are using VS code. That's the file 602 | with the settings for VS code to use. If you'd like more details about other 603 | configuration options like the ones for using the debugger or running tasks, see 604 | the [development tools guide](development-tools.md#vs-code) for more 605 | information. 606 | 607 | #### .vscode/settings.json 608 | 609 | Your vscode settings must have your deno.jsonc referenced so that the extension 610 | knows how to work with your project. You can use unstable APIs if you want but 611 | by default I left that disabled in this example. Your code will automatically 612 | get formatted by the deno extension when you save. If there are parts of your 613 | code you would like ignored by Deno's linter or formatter, you can configure 614 | that in your [deno.jsonc](#denojsonc) file. 615 | 616 | ```json 617 | { 618 | "deno.enable": true, 619 | "deno.lint": true, 620 | "deno.unstable": false, 621 | "deno.config": "./deno.jsonc", 622 | "editor.formatOnSave": true, 623 | "editor.defaultFormatter": "denoland.vscode-deno", 624 | "editor.quickSuggestions": { 625 | "strings": true 626 | } 627 | } 628 | ``` 629 | 630 | ## Tasks 631 | 632 | To run the tests, use `deno task test` or `deno task test-watch`. 633 | 634 | To check formatting and run the linter, use `deno task check`. 635 | 636 | The following 2 commands can be used for creating builds. 637 | 638 | - `deno task build-dev`: Builds the application in development mode. 639 | - `deno task build-prod`: Builds the application in production mode. 640 | 641 | A build must be generated before you can run an application. You can use the 642 | following 2 commands to run the application. 643 | 644 | - `deno task run-dev`: Runs the application in development mode. 645 | - `deno task run-prod`: Runs the application in production mode. 646 | 647 | To run the application in development mode with live reloading, use 648 | `deno task dev`. 649 | 650 | When in development, identifiers are not minified and sourcemaps are generated 651 | and linked. 652 | 653 | The commands ending in `-dev` and `-prod` set the `APP_ENV` and `NODE_ENV` 654 | environment variables. The `NODE_ENV` environment variable is needed for react. 655 | If you use the `deno task build` or `deno task run` tasks, you should make sure 656 | that you set both of those environment variables. Those environment variables 657 | are also needed if you deploy to Deno Deploy. 658 | 659 | The `deno task git-rebase` task is useful if you use squash and merge. If you 660 | don't need it feel free to remove it. 661 | 662 | If you'd like to customize your build or the tasks available, see the 663 | [tasks section](configuration.md#tasks) of our 664 | [configuration guide](configuration.md). 665 | 666 | ## Routing 667 | 668 | This framework supports both file based routing and nesting routes within a 669 | file. This makes it easy to organize your application and visualize it as a tree 670 | just like how React makes it easy to organize and visualize your UI as a tree. 671 | 672 | To learn more about routing, view our [routing guide](routing.md). 673 | 674 | ## Error handling 675 | 676 | Error handling is a crucial aspect of both UI and API routes in this framework. 677 | It allows you to gracefully manage and respond to various types of errors that 678 | may occur during the execution of your application. 679 | 680 | For more detailed information on implementing error handling in both UI and API 681 | routes, including best practices and advanced techniques, please refer to our 682 | comprehensive [error handling guide](./error-handling.md). 683 | 684 | ## Metadata 685 | 686 | Metadata is crucial for improving SEO, social media sharing, and overall user 687 | experience in your application. It is also where you can add scripts and styles 688 | to your application. 689 | 690 | For detailed information on implementing and managing metadata, including best 691 | practices and advanced techniques, please refer to our 692 | [Metadata guide](./metadata.md). For more information on how to style your 693 | application, please refer to the [Styling guide](./styling.md). 694 | 695 | ## Logging 696 | 697 | TODO: Briefly cover how to log and how it can be configured. 698 | 699 | For more information, view our [logging guide](logging.md). 700 | 701 | ## Testing 702 | 703 | TODO: Cover the basics of testing then link to the testing guide. 704 | 705 | For more information, view our [testing guide](testing.md). 706 | 707 | ## Deployment 708 | 709 | TODO: Link to the CI-CD guide section for deploymnet for more information about 710 | deploying to other environments and for automating deployment with github 711 | actions. 712 | -------------------------------------------------------------------------------- /docs/http-middleware.md: -------------------------------------------------------------------------------- 1 | # HTTP middleware 2 | 3 | TODO: Make an outline then fill it in with details. 4 | 5 | - [HTTP middleware](#http-middleware) 6 | -------------------------------------------------------------------------------- /docs/logging.md: -------------------------------------------------------------------------------- 1 | # Logging 2 | 3 | TODO: Make an outline then fill it in with details. 4 | 5 | - [Logging](#logging) 6 | - [Console](#console) 7 | - [Browser](#browser) 8 | - [Files](#files) 9 | 10 | ## Console 11 | 12 | ## Browser 13 | 14 | ## Files 15 | 16 | TODO: Link to Deno's logging documentation that explains how logs can be written 17 | to files. 18 | -------------------------------------------------------------------------------- /docs/metadata.md: -------------------------------------------------------------------------------- 1 | # Metadata 2 | 3 | - [Metadata](#metadata) 4 | - [Setting metadata](#setting-metadata) 5 | - [SEO](#seo) 6 | 7 | ## Setting metadata 8 | 9 | [React Helmet Async](https://www.npmjs.com/package/react-helmet-async) is used 10 | to manage all of your changes to the document head. You can add a Helmet tag to 11 | any page that you would like to update the document head. 12 | 13 | - Supports all valid head tags: title, base, meta, link, script, noscript, and 14 | style tags. 15 | - Supports attributes for body, html and title tags. 16 | 17 | The following example can be found in the [main route](example/routes/main.tsx) 18 | of the example in this repository. The Helmet in the main route of a directory 19 | will apply to all routes within the directory. 20 | 21 | ```tsx 22 | import { Suspense } from "react"; 23 | import { DefaultErrorFallback, ErrorBoundary, Helmet } from "@udibo/react-app"; 24 | import { Outlet } from "react-router-dom"; 25 | import "../log.ts"; 26 | 27 | import { Loading } from "../components/loading.tsx"; 28 | 29 | export default function Main() { 30 | return ( 31 | <> 32 | 37 | 38 | 39 | 40 | }> 41 | 42 | 43 | 44 | 45 | 46 | ); 47 | } 48 | ``` 49 | 50 | More examples of Helmet tag usage can be found in the 51 | [React Helmet Reference Guide](https://github.com/nfl/react-helmet#reference-guide). 52 | 53 | ## SEO 54 | 55 | TODO: Explain the basics of setting up SEO metadata and using lighthouse to test 56 | it. 57 | -------------------------------------------------------------------------------- /docs/state-management.md: -------------------------------------------------------------------------------- 1 | # State management 2 | 3 | TODO: Make an outline then fill it in with details. 4 | 5 | - [State management](#state-management) 6 | - [Initial state](#initial-state) 7 | - [React](#react) 8 | - [State](#state) 9 | - [Context](#context) 10 | - [Reducers](#reducers) 11 | - [React Router](#react-router) 12 | - [React Query](#react-query) 13 | 14 | ## Initial state 15 | 16 | TODO: Cover the basics of passing state to the client via the apps initialState 17 | property. 18 | 19 | ## React 20 | 21 | ### State 22 | 23 | ### Context 24 | 25 | ### Reducers 26 | 27 | ## React Router 28 | 29 | ## React Query 30 | -------------------------------------------------------------------------------- /docs/static-files.md: -------------------------------------------------------------------------------- 1 | # Static files 2 | 3 | TODO: Make an outline then fill it in with details. Mention that build artifacts 4 | will be stored in the public build directory. 5 | 6 | - [Static files](#static-files) 7 | -------------------------------------------------------------------------------- /docs/styling.md: -------------------------------------------------------------------------------- 1 | # Styling 2 | 3 | TODO: Make an outline then fill it in with details. 4 | 5 | - [Styling](#styling) 6 | - [CSS](#css) 7 | - [PostCSS](#postcss) 8 | - [Tailwindcss](#tailwindcss) 9 | - [Sass](#sass) 10 | - [Less](#less) 11 | 12 | ## CSS 13 | 14 | ## PostCSS 15 | 16 | ### Tailwindcss 17 | 18 | ## Sass 19 | 20 | ## Less 21 | -------------------------------------------------------------------------------- /docs/testing.md: -------------------------------------------------------------------------------- 1 | # Testing 2 | 3 | TODO: Make an outline then fill it in with details. 4 | 5 | - [Testing](#testing) 6 | - [Running tests](#running-tests) 7 | - [Deno](#deno) 8 | - [Docker](#docker) 9 | - [Writing tests](#writing-tests) 10 | - [Server](#server) 11 | - [User interface](#user-interface) 12 | - [React testing library](#react-testing-library) 13 | - [Playwright](#playwright) 14 | - [Coverage reports](#coverage-reports) 15 | 16 | ## Running tests 17 | 18 | ### Deno 19 | 20 | TODO: Talk about using the test tasks. Link to the development tools guide for 21 | more details about the tasks. 22 | 23 | ### Docker 24 | 25 | TODO: Explain how docker can be used for running application dependencies like 26 | postgres along with how to setup running your tests in a docker container. 27 | 28 | ## Writing tests 29 | 30 | TODO: Cover the basics of writing tests, linking to Deno's guides on that 31 | subject. Be sure to mention BDD, Mocking, Fake time and timers, Snapshot 32 | testing, and type assertions. 33 | 34 | ### Server 35 | 36 | TODO: Cover writing tests for server routes. End to end tests for endpoints for 37 | both the API and UI. Use snapshot testing for UI route responses since they 38 | return HTML pages. 39 | 40 | ### User interface 41 | 42 | TODO: Cover testing components and writing end to end tests for routes using 43 | react testing library and playwright 44 | 45 | #### React testing library 46 | 47 | #### Playwright 48 | 49 | TODO: See https://github.com/denoland/deno/issues/16899 for information about 50 | this. Write a guide on it. If possible include a link to how to include it's 51 | coverage in the coverage reports. 52 | 53 | ## Coverage reports 54 | 55 | TODO: Write how to see coverage in console. Link to development tools section on 56 | using codecov. 57 | -------------------------------------------------------------------------------- /env.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals, assertStrictEquals } from "@std/assert"; 2 | import { describe, it } from "@std/testing/bdd"; 3 | 4 | import { 5 | isBrowser, 6 | isDevelopment, 7 | isProduction, 8 | isServer, 9 | isTest, 10 | } from "./env.ts"; 11 | import { startBrowser, startEnvironment } from "./test-utils.ts"; 12 | 13 | const isBrowserTests = describe("isBrowser"); 14 | 15 | it(isBrowserTests, "false in server", () => { 16 | assertEquals(isBrowser(), false); 17 | }); 18 | 19 | it(isBrowserTests, "true in browser", () => { 20 | using _browser = startBrowser(); 21 | assertEquals(isBrowser(), true); 22 | }); 23 | 24 | const isServerTests = describe("isServer"); 25 | 26 | it(isServerTests, "true in server", () => { 27 | assertEquals(isServer(), true); 28 | }); 29 | 30 | it(isServerTests, "false in browser", () => { 31 | using _browser = startBrowser(); 32 | assertEquals(isServer(), false); 33 | }); 34 | 35 | const isTestTests = describe("isTest"); 36 | 37 | it(isTestTests, "true in test", () => { 38 | assertStrictEquals(isTest(), true); 39 | }); 40 | 41 | it(isTestTests, "false in other environments", () => { 42 | function checkEnvironment(appEnvironment: string) { 43 | using _environment = startEnvironment(appEnvironment); 44 | assertStrictEquals(isTest(), false); 45 | } 46 | checkEnvironment("development"); 47 | checkEnvironment("production"); 48 | checkEnvironment("fake"); 49 | }); 50 | 51 | const isDevelopmentTests = describe("isDevelopment"); 52 | 53 | it(isDevelopmentTests, "true in development", () => { 54 | using _environment = startEnvironment("development"); 55 | assertStrictEquals(isDevelopment(), true); 56 | }); 57 | 58 | it(isDevelopmentTests, "false in other environments", () => { 59 | assertStrictEquals(isDevelopment(), false); 60 | function checkEnvironment(appEnvironment: string) { 61 | using _environment = startEnvironment(appEnvironment); 62 | assertStrictEquals(isDevelopment(), false); 63 | } 64 | checkEnvironment("production"); 65 | checkEnvironment("fake"); 66 | }); 67 | 68 | const isProductionTests = describe("isProduction"); 69 | 70 | it(isProductionTests, "true in production", () => { 71 | using _environment = startEnvironment("production"); 72 | assertStrictEquals(isProduction(), true); 73 | }); 74 | 75 | it(isProductionTests, "false in other environments", () => { 76 | assertStrictEquals(isProduction(), false); 77 | function checkEnvironment(appEnvironment: string) { 78 | using _environment = startEnvironment(appEnvironment); 79 | assertStrictEquals(isProduction(), false); 80 | } 81 | checkEnvironment("development"); 82 | checkEnvironment("fake"); 83 | }); 84 | -------------------------------------------------------------------------------- /env.ts: -------------------------------------------------------------------------------- 1 | import type { HttpErrorOptions } from "./error.tsx"; 2 | 3 | /** A constant used for internal purposes only. */ 4 | export const _env = { 5 | /** Inidicates whether the code is running on the server or not. */ 6 | isServer: "Deno" in globalThis || "process" in globalThis, 7 | }; 8 | 9 | /** A JSON representation of the initial app state. */ 10 | export interface AppData< 11 | SharedState extends Record = Record< 12 | string | number, 13 | unknown 14 | >, 15 | > { 16 | /** The environment that the application is running in. */ 17 | env: string; 18 | /** The initial state for the app. */ 19 | initialState: SharedState; 20 | /** The error that occurred when rendering the apps initially. */ 21 | error?: HttpErrorOptions; 22 | /** The port for the dev script's live reload server. Defaults to 9001. */ 23 | devPort?: number; 24 | } 25 | 26 | /** 27 | * A type representing the browser's window object augmented with an `app` property that is used for internal purposes only. 28 | */ 29 | export type AppWindow< 30 | SharedState extends Record = Record< 31 | string | number, 32 | unknown 33 | >, 34 | > = typeof window & { 35 | app?: AppData; 36 | }; 37 | 38 | /** 39 | * A function that returns a boolean indicating whether the code is running on the server or not. 40 | * 41 | * This function can be used for disabling server side rendering (SSR) for components. 42 | * 43 | * In the following example, the BlogPost component will show a loading message initially when it is rendered on the server. 44 | * After the component is hydrated in the browser, the loading message will be replaced with the actual content once the request to get the post completes. 45 | * This example comes from `example/routes/blog/[id].tsx` and the only change is to import `isServer` and to not call `getPost(id)` if it returns `true`. 46 | * 47 | * ```tsx 48 | * import { useParams } from "react-router-dom"; 49 | * import { Helmet, HttpError, isServer } from "@udibo/react-app"; 50 | * 51 | * import { getPost } from "../../services/posts.tsx"; 52 | * 53 | * export default function BlogPost() { 54 | * const params = useParams(); 55 | * const id = Number(params.id); 56 | * if (isNaN(id) || Math.floor(id) !== id || id < 0) { 57 | * throw new HttpError(400, "Invalid id"); 58 | * } 59 | * 60 | * const post = isServer() ? null : getPost(id); 61 | * return post 62 | * ? ( 63 | * <> 64 | * 65 | * {post.title} 66 | * 67 | * 68 | *

{post.title}

69 | *

{post.content}

70 | * 71 | * ) 72 | * : ( 73 | * <> 74 | * 75 | * Loading... 76 | * 77 | *

Loading...

78 | * 79 | * ); 80 | * } 81 | * ``` 82 | * 83 | * @returns A boolean indicating whether the code is running on the server or not. 84 | */ 85 | export function isServer(): boolean { 86 | return _env.isServer; 87 | } 88 | 89 | /** 90 | * A function that returns a boolean indicating whether the code is running in the browser or not. 91 | * 92 | * @returns A boolean indicating whether the code is running in the browser or not. 93 | */ 94 | export function isBrowser(): boolean { 95 | return !isServer(); 96 | } 97 | 98 | /** 99 | * A function that returns the environment the application is running in. 100 | * On the server, this value comes from the `APP_ENV` environment variable. 101 | * In the browser, this value is set on the `window.app.env` property. 102 | * If it is not set in either case, the default value is `development`. 103 | * 104 | * @returns The environment that the application is running in. 105 | */ 106 | export function getEnvironment(): string { 107 | return (isServer() 108 | ? Deno.env.get("APP_ENV") 109 | : (globalThis.window as AppWindow).app?.env) ?? "development"; 110 | } 111 | 112 | /** 113 | * A function that returns a boolean indicating whether the code is running in the test environment or not. 114 | * 115 | * @returns A boolean indicating whether the code is running in the test environment or not. 116 | */ 117 | export function isTest(): boolean { 118 | return getEnvironment() === "test"; 119 | } 120 | 121 | /** 122 | * A function that returns a boolean indicating whether the code is running in the development environment or not. 123 | * 124 | * @returns A boolean indicating whether the code is running in the development environment or not. 125 | */ 126 | export function isDevelopment(): boolean { 127 | const env = getEnvironment(); 128 | return !env || env === "development"; 129 | } 130 | 131 | /** 132 | * A function that returns a boolean indicating whether the code is running in the production environment or not. 133 | * 134 | * @returns A boolean indicating whether the code is running in the production environment or not. 135 | */ 136 | export function isProduction(): boolean { 137 | return getEnvironment() === "production"; 138 | } 139 | -------------------------------------------------------------------------------- /error.test.tsx: -------------------------------------------------------------------------------- 1 | import "./error.tsx"; 2 | -------------------------------------------------------------------------------- /error.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * This module provides utilities for handling errors in a React application. 3 | * It includes the http-error module for creating and handling HTTP errors. 4 | * 5 | * @module 6 | */ 7 | /** @jsxRuntime automatic */ 8 | /** @jsxImportSource npm:react@18 */ 9 | /** @jsxImportSourceTypes npm:@types/react@18 */ 10 | import { 11 | ErrorResponse, 12 | HttpError, 13 | isErrorResponse, 14 | isHttpError, 15 | } from "@udibo/http-error"; 16 | import type { HttpErrorOptions } from "@udibo/http-error"; 17 | import { 18 | isValidElement, 19 | useContext, 20 | useEffect, 21 | useMemo, 22 | useState, 23 | } from "react"; 24 | import type { 25 | ComponentType, 26 | PropsWithChildren, 27 | ReactElement, 28 | ReactNode, 29 | } from "react"; 30 | import { useLocation } from "react-router-dom"; 31 | import { ErrorBoundary as ReactErrorBoundary } from "react-error-boundary"; 32 | import type { 33 | ErrorBoundaryProps as ReactErrorBoundaryProps, 34 | FallbackProps, 35 | } from "react-error-boundary"; 36 | 37 | import { isDevelopment } from "./env.ts"; 38 | import { ErrorContext } from "./context.ts"; 39 | 40 | export { ErrorResponse, HttpError, isErrorResponse, isHttpError }; 41 | export type { FallbackProps, HttpErrorOptions }; 42 | 43 | /** The props for an application error boundary. */ 44 | export type ErrorBoundaryProps = ReactErrorBoundaryProps & { 45 | /** Used to associate errors on the server side with the boundary when using server side rendering. */ 46 | boundary?: string; 47 | }; 48 | 49 | /** 50 | * A component that captures any errors within the boundary. 51 | * For an error on the server to be caught by it, it must have the same `boundary` property. 52 | * 53 | * This component can either be used directly or as a higher-order component with the `withErrorBoundary` function. 54 | * 55 | * If your route is exporting an `ErrorFallback` component, you don't need to use this component directly. 56 | * Your route component will be automatically wrapped with an error boundary. 57 | * 58 | * In the following example, the `DisplayId` component will throw a 400 bad request error if the id is not a positive integer. 59 | * If an error occurs, it will be caught by the `ErrorBoundary` and the `ErrorFallback` component will be rendered. 60 | * 61 | * ```tsx 62 | * import { useParams } from "react-router-dom"; 63 | * import { ErrorBoundary, DefaultErrorFallback, HttpError } from "@udibo/react-app"; 64 | * 65 | * export const boundary = "MyPage"; 66 | * export const ErrorFallback = DefaultErrorFallback; 67 | * 68 | * export defualt function DisplayId(): ReactElement { 69 | * const params = useParams(); 70 | * const id = Number(params.id); 71 | * if (isNaN(id) || Math.floor(id) !== id || id < 0) { 72 | * throw new HttpError(400, "Invalid id"); 73 | * } 74 | * return ( 75 | * <> 76 | *

Id

77 | *

{id}

78 | * 79 | * ); 80 | * } 81 | * ``` 82 | * 83 | * If no boundary parameter is provided to the component, it will capture any errors that occur within it that are not captured already. 84 | * This should only be used without a boundary at the top level of the app. 85 | * 86 | * ```tsx 87 | * import { ErrorBoundary, DefaultErrorFallback } from "@udibo/react-app"; 88 | * import { Outlet } from "react-router-dom"; 89 | * 90 | * import { Loading } from "../components/Loading.tsx"; 91 | * 92 | * export default function Main(): ReactElement { 93 | * return ( 94 | * <> 95 | *

My App

96 | * }> 97 | * 98 | * 99 | * 100 | * 101 | * 102 | * ); 103 | * } 104 | * ``` 105 | * 106 | * If you'd like to nest an error boundary within your route component, you can use the `boundary` prop to associate errors with it. 107 | * 108 | * In the following example, the `DisplayId` component will throw a 400 bad request error if the id is not a positive integer. 109 | * If an error occurs, it will be caught by the `ErrorBoundary` within the `MyPage` component and the `DefaultErrorFallback` component will be rendered. 110 | * 111 | * ```tsx 112 | * import { useParams } from "react-router-dom"; 113 | * import { ErrorBoundary, DefaultErrorFallback, HttpError } from "@udibo/react-app"; 114 | * 115 | * export const boundary = "MyPage"; 116 | * 117 | * function DisplayId(): ReactElement { 118 | * const params = useParams(); 119 | * const id = Number(params.id); 120 | * if (isNaN(id) || Math.floor(id) !== id || id < 0) { 121 | * throw new HttpError(400, "Invalid id"); 122 | * } 123 | * return ( 124 | * <> 125 | *

Id

126 | *

{id}

127 | * 128 | * ); 129 | * } 130 | * 131 | * export defualt function MyPage(): ReactElement { 132 | * return ( 133 | *
134 | *

My Component

135 | * 139 | * 140 | * 141 | *
142 | * ); 143 | * } 144 | * ``` 145 | * 146 | * Because a boundary string is exported from that route, error boundary middleware will automatically be added to the server side route 147 | * to capture any errors that occur within it and associate them with that boundary. 148 | * 149 | * In the following example, the route will throw a 400 bad request error if the id is not a positive integer. 150 | * It will be associated with the `MyPage` boundary. 151 | * 152 | * ```ts 153 | * import { HttpError } from "@udibo/react-app"; 154 | * import { ServerState } from "@udibo/react-app/server"; 155 | * import { Router } from "@oak/oak"; 156 | * 157 | * export default new Router() 158 | * .get("/", async (context) => { 159 | * const { state, params } = context; 160 | * const id = Number(params.id); 161 | * if (isNaN(id) || Math.floor(id) !== id || id < 0) { 162 | * throw new HttpError(400, "Invalid id"); 163 | * } 164 | * await state.app.render(); 165 | * }); 166 | * ``` 167 | * 168 | * On the server, you can use the `errorBoundary` middleware to associate server side errors with a boundary. 169 | * If you do that, your route doesn't need to export a boundary. 170 | * 171 | * ```ts 172 | * import { HttpError } from "@udibo/react-app"; 173 | * import { errorBoundary, ServerState } from "@udibo/react-app/server"; 174 | * import { Router } from "@oak/oak"; 175 | * 176 | * export default new Router() 177 | * .use(errorBoundary("MyPage")) 178 | * .get("/", async (context) => { 179 | * const { state, params } = context; 180 | * const id = Number(params.id); 181 | * if (isNaN(id) || Math.floor(id) !== id || id < 0) { 182 | * throw new HttpError(400, "Invalid id"); 183 | * } 184 | * await state.app.render(); 185 | * }); 186 | * ``` 187 | * 188 | * You can also manually assign boundaries to errors. This is useful if your page has multiple error boundaries. 189 | * 190 | * In the following example, the `DisplayId` component will throw a 400 bad request error if the id is not a positive integer. 191 | * That error will have a boundary of "DisplayId" and will be handled by the `ErrorBoundary` with the same boundary. 192 | * If any other errors occur outside of that boundary, they will be handled by the `ErrorBoundary` with the boundary "MyPage". 193 | * 194 | * ```tsx 195 | * import { useParams } from "react-router-dom"; 196 | * import { ErrorBoundary, DefaultErrorFallback, HttpError } from "@udibo/react-app"; 197 | * 198 | * export const boundary = "MyPage"; 199 | * export const ErrorFallback = DefaultErrorFallback; 200 | * 201 | * function DisplayId(): ReactElement { 202 | * const params = useParams(); 203 | * const id = Number(params.id); 204 | * if (isNaN(id) || Math.floor(id) !== id || id < 0) { 205 | * throw new HttpError(400, "Invalid id", { boundary: "DisplayId" }); 206 | * } 207 | * return ( 208 | * <> 209 | *

Id

210 | *

{id}

211 | * 212 | * ); 213 | * } 214 | * 215 | * export defualt function MyPage(): ReactElement { 216 | * return ( 217 | *
218 | *

My Component

219 | * 223 | * 224 | * 225 | *
226 | * ); 227 | * } 228 | * ``` 229 | * 230 | * ```ts 231 | * import { HttpError } from "@udibo/react-app"; 232 | * import { ServerState } from "@udibo/react-app/server"; 233 | * import { Router } from "@oak/oak"; 234 | * 235 | * export default new Router() 236 | * .get("/", async (context) => { 237 | * const { state, params } = context; 238 | * const id = Number(params.id); 239 | * if (isNaN(id) || Math.floor(id) !== id || id < 0) { 240 | * throw new HttpError(400, "Invalid id", { boundary: "DisplayId" }); 241 | * } 242 | * await state.app.render(); 243 | * }); 244 | * ``` 245 | * 246 | * @param props - The properties for an application error boundary. 247 | * @returns An application error boundary component. 248 | */ 249 | export function ErrorBoundary( 250 | props: PropsWithChildren, 251 | ): ReactNode { 252 | const { children, boundary, ...errorBoundaryProps } = props; 253 | const errorContext = useContext(ErrorContext); 254 | const initialError = errorContext.error ?? null; 255 | const [error, setError] = useState(initialError); 256 | 257 | useEffect(() => { 258 | return () => { 259 | setTimeout(() => { 260 | delete errorContext.error; 261 | }, 0); 262 | }; 263 | }, []); 264 | 265 | if (!error || boundary !== error.data.boundary) { 266 | return ( 267 | 270 | {children} 271 | 272 | ); 273 | } 274 | 275 | const fallbackProps = { 276 | error, 277 | resetErrorBoundary: (...args) => { 278 | setError(null); 279 | errorBoundaryProps.onReset?.({ 280 | args, 281 | reason: "imperative-api", 282 | }); 283 | }, 284 | } as FallbackProps; 285 | 286 | const { fallback, fallbackRender, FallbackComponent } = errorBoundaryProps; 287 | if (isValidElement(fallback)) { 288 | return fallback; 289 | } else if (typeof fallbackRender === "function") { 290 | return fallbackRender(fallbackProps); 291 | } else if (FallbackComponent) { 292 | return ; 293 | } else { 294 | throw new Error( 295 | "ErrorBoundary requires either a fallback, fallbackRender, or FallbackComponent prop.", 296 | ); 297 | } 298 | } 299 | 300 | /** 301 | * A higher-order component that wraps a component with an `ErrorBoundary`. Any errors within the boundary will be captured by it. 302 | * For the error on the server to be caught by it, it must have the same `boundary`. 303 | * 304 | * In the following example, the `DisplayId` component will throw a 400 bad request error if the id is not a positive integer. 305 | * If an error occurs, it will be caught by the `ErrorBoundary` that wraps the DisplayId component and the `DefaultErrorFallback` component will be rendered. 306 | * 307 | * ```tsx 308 | * import { useParams } from "react-router-dom"; 309 | * import { ErrorBoundary, DefaultErrorFallback, HttpError } from "@udibo/react-app"; 310 | * 311 | * function DisplayId(): ReactElement { 312 | * const params = useParams(); 313 | * const id = Number(params.id); 314 | * if (isNaN(id) || Math.floor(id) !== id || id < 0) { 315 | * throw new HttpError(400, "Invalid id", { boundary: "DisplayId" }); 316 | * } 317 | * return ( 318 | * <> 319 | *

Id

320 | *

{id}

321 | * 322 | * ); 323 | * } 324 | * 325 | * export default withErrorBoundary(DisplayId, { FallbackComponent: DefaultErrorFallback, boundary: "DisplayId" }); 326 | * ``` 327 | * 328 | * @param Component - The component to wrap with an error boundary. 329 | * @param errorBoundaryProps - The properties for the error boundary. 330 | * @returns A component that is wrapped with an error boundary. 331 | */ 332 | export function withErrorBoundary

( 333 | Component: ComponentType

, 334 | errorBoundaryProps: ErrorBoundaryProps, 335 | ): ComponentType

{ 336 | const Wrapped = ((props: P) => { 337 | return ( 338 | 339 | 345 | 346 | ); 347 | }) as ComponentType

; 348 | 349 | // Format for display in DevTools 350 | const name = Component.displayName || Component.name || "Unknown"; 351 | Wrapped.displayName = `withErrorBoundary(${name})`; 352 | 353 | return Wrapped; 354 | } 355 | 356 | /** 357 | * A hook that automatically resets the error boundary if the location changes. 358 | * It takes a `reset` function and generates a new reset function that prevents multiple calls to the reset callback. 359 | * 360 | * Here is an example of a simple ErrorFallback component that uses the `useAutoReset` hook: 361 | * 362 | * ```tsx 363 | * import { useAutoReset } from "@udibo/react-app"; 364 | * 365 | * export function ErrorFallback({ error, resetErrorBoundary }: FallbackProps): ReactElement { 366 | * const reset = useAutoReset(resetErrorBoundary); 367 | * 368 | * return ( 369 | *

370 | *

{error.message || "Something went wrong"}

371 | * {isDevelopment() && error.stack ?
{error.stack}
: null} 372 | * 373 | *
374 | * ); 375 | * } 376 | * ``` 377 | * 378 | * That example is the same as the `DefaultErrorFallback` component in this module. 379 | * 380 | * @param reset - A function for resetting the error boundary. 381 | * @returns A reset function that automatically resets the error boundary if the location changes. 382 | */ 383 | export function useAutoReset(reset: () => void): () => void { 384 | const location = useLocation(); 385 | const [initialLocation] = useState(location); 386 | 387 | const resetOnce = useMemo(() => { 388 | let resetCalled = false; 389 | return () => { 390 | if (!resetCalled) { 391 | resetCalled = true; 392 | reset(); 393 | } 394 | }; 395 | }, []); 396 | 397 | useEffect(() => { 398 | if (location !== initialLocation) resetOnce(); 399 | }, [location]); 400 | 401 | return reset; 402 | } 403 | 404 | /** 405 | * A simple error fallback component that can be used to show the error and provide a button for trying again. 406 | * It takes a `FallbackProps` object with an `error` property, which represents the error that occurred, and 407 | * a `resetErrorBoundary` function which is used to reset the error boundary. 408 | * The error boundary automatically resets if the location changes. 409 | * 410 | * @param props - The properties for the error fallback. 411 | * @returns A simple error fallback component. 412 | */ 413 | export function DefaultErrorFallback( 414 | { error, resetErrorBoundary }: FallbackProps, 415 | ): ReactElement { 416 | const reset = useAutoReset(resetErrorBoundary); 417 | 418 | return ( 419 |
420 |

{error.message || "Something went wrong"}

421 | {isDevelopment() && error.stack ?
{error.stack}
: null} 422 | 423 |
424 | ); 425 | } 426 | 427 | /** 428 | * A component that can be used to throw a 404 not found error. It is used as the default wildcard route at the top level of the app. 429 | * 430 | * @returns This function never returns, it always throws a 404 not found error. 431 | */ 432 | export function NotFound(): ReactElement { 433 | throw new HttpError(404, "Not found"); 434 | } 435 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | # Build 2 | public/build 3 | public/test-build 4 | routes/_main.tsx 5 | routes/_main.ts 6 | 7 | # VSCode 8 | .vscode/*.log 9 | 10 | # Coverage 11 | coverage 12 | 13 | # Node modules 14 | node_modules 15 | -------------------------------------------------------------------------------- /example/build.ts: -------------------------------------------------------------------------------- 1 | import { buildOnce, type BuildOptions } from "@udibo/react-app/build"; 2 | import "./log.ts"; 3 | 4 | export const buildOptions: BuildOptions = { 5 | configPath: "../deno.jsonc", 6 | }; 7 | 8 | if (import.meta.main) { 9 | buildOnce(buildOptions); 10 | } 11 | -------------------------------------------------------------------------------- /example/components/loading.test.tsx: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "@std/assert"; 2 | import { describe, it } from "@std/testing/bdd"; 3 | 4 | import { render } from "../test-utils.tsx"; 5 | 6 | import { Loading } from "./loading.tsx"; 7 | 8 | const loadingTests = describe("Loading component"); 9 | 10 | it(loadingTests, "renders loading message", () => { 11 | using screen = render(); 12 | assertEquals(screen.getByText("Loading...").textContent, "Loading..."); 13 | }); 14 | -------------------------------------------------------------------------------- /example/components/loading.tsx: -------------------------------------------------------------------------------- 1 | export function Loading() { 2 | return
Loading...
; 3 | } 4 | -------------------------------------------------------------------------------- /example/data/posts.ts: -------------------------------------------------------------------------------- 1 | import type { Post } from "../models/posts.ts"; 2 | 3 | export const posts: { [id: number]: Post } = { 4 | 0: { 5 | id: 0, 6 | title: "My first post", 7 | content: "This is my first post.", 8 | }, 9 | 1: { 10 | id: 1, 11 | title: "My second post", 12 | content: "This is my second post.", 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /example/dev.ts: -------------------------------------------------------------------------------- 1 | import { startDev } from "@udibo/react-app/dev"; 2 | import "./log.ts"; 3 | 4 | import { buildOptions } from "./build.ts"; 5 | 6 | startDev({ 7 | buildOptions, 8 | }); 9 | -------------------------------------------------------------------------------- /example/log.ts: -------------------------------------------------------------------------------- 1 | import * as log from "@std/log"; 2 | import { isDevelopment, isServer, logFormatter } from "@udibo/react-app"; 3 | 4 | const level = isDevelopment() ? "DEBUG" : "INFO"; 5 | log.setup({ 6 | handlers: { 7 | default: new log.ConsoleHandler(level, { 8 | formatter: logFormatter, 9 | useColors: isServer(), 10 | }), 11 | }, 12 | loggers: { "react-app": { level, handlers: ["default"] } }, 13 | }); 14 | -------------------------------------------------------------------------------- /example/main.ts: -------------------------------------------------------------------------------- 1 | import * as path from "@std/path"; 2 | import { serve } from "@udibo/react-app/server"; 3 | 4 | import route from "./routes/_main.tsx"; 5 | import router from "./routes/_main.ts"; 6 | import "./log.ts"; 7 | 8 | await serve({ 9 | port: 9000, 10 | router, 11 | route, 12 | workingDirectory: path.dirname(path.fromFileUrl(import.meta.url)), 13 | }); 14 | -------------------------------------------------------------------------------- /example/models/posts.ts: -------------------------------------------------------------------------------- 1 | export interface Post { 2 | id: number; 3 | title: string; 4 | content: string; 5 | } 6 | 7 | export interface Posts { 8 | [id: number]: Post; 9 | } 10 | 11 | export type PostsState = { 12 | posts?: Posts; 13 | }; 14 | -------------------------------------------------------------------------------- /example/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/udibo/react-app/cf75f450ad35e38b4af2ba23a1315ff5b9d75bc8/example/public/favicon.ico -------------------------------------------------------------------------------- /example/routes/about.tsx: -------------------------------------------------------------------------------- 1 | import { Helmet } from "@udibo/react-app"; 2 | 3 | export default function About() { 4 | return ( 5 | <> 6 | 7 | About 8 | 12 | 13 |

About

14 |

Udibo React App

15 |

A React Framework for Deno.

16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /example/routes/api/blog/posts.ts: -------------------------------------------------------------------------------- 1 | import { HttpError } from "@udibo/react-app"; 2 | import { Router } from "@udibo/react-app/server"; 3 | 4 | import { getPost, getPosts } from "../../../services/posts.ts"; 5 | import type { PostsState } from "../../../models/posts.ts"; 6 | 7 | export default new Router() 8 | .get("/", (context) => { 9 | const { response } = context; 10 | response.body = getPosts(); 11 | }) 12 | .get("/:id", (context) => { 13 | const { response, params } = context; 14 | const id = parseFloat(params.id); 15 | if (isNaN(id) || Math.floor(id) !== id || id < 0) { 16 | throw new HttpError(400, "Invalid id"); 17 | } 18 | const post = getPost(id); 19 | if (!post) throw new HttpError(404, "Not found"); 20 | response.body = post; 21 | }); 22 | -------------------------------------------------------------------------------- /example/routes/blog/[id].ts: -------------------------------------------------------------------------------- 1 | import { HttpError } from "@udibo/react-app"; 2 | import { Router } from "@udibo/react-app/server"; 3 | 4 | import { getPost } from "../../services/posts.ts"; 5 | import type { PostsState } from "../../models/posts.ts"; 6 | 7 | export default new Router() 8 | .get("/", async (context) => { 9 | const { state, params } = context; 10 | const id = Number(params.id); 11 | if (isNaN(id) || Math.floor(id) !== id || id < 0) { 12 | throw new HttpError(400, "Invalid id"); 13 | } 14 | 15 | state.app.initialState.posts = { 16 | [id]: getPost(id), 17 | }; 18 | await state.app.render(); 19 | }); 20 | -------------------------------------------------------------------------------- /example/routes/blog/[id].tsx: -------------------------------------------------------------------------------- 1 | import { useParams } from "react-router-dom"; 2 | import { Helmet, HttpError } from "@udibo/react-app"; 3 | 4 | import { getPost } from "../../services/posts.tsx"; 5 | 6 | export default function BlogPost() { 7 | const params = useParams(); 8 | const id = Number(params.id); 9 | if (isNaN(id) || Math.floor(id) !== id || id < 0) { 10 | throw new HttpError(400, "Invalid id"); 11 | } 12 | const post = getPost(id); 13 | return post 14 | ? ( 15 | <> 16 | 17 | {post.title} 18 | 19 | 20 |

{post.title}

21 |

{post.content}

22 | 23 | ) 24 | : ( 25 | <> 26 | 27 | Loading... 28 | 29 |

Loading...

30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /example/routes/blog/index.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "@udibo/react-app/server"; 2 | 3 | import { getPosts } from "../../services/posts.ts"; 4 | import type { PostsState } from "../../models/posts.ts"; 5 | 6 | export default new Router() 7 | .get("/", async (context) => { 8 | const { state } = context; 9 | 10 | state.app.initialState.posts = getPosts(); 11 | await state.app.render(); 12 | }); 13 | -------------------------------------------------------------------------------- /example/routes/blog/index.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "react-router-dom"; 2 | import { Helmet } from "@udibo/react-app"; 3 | 4 | import { getPosts } from "../../services/posts.tsx"; 5 | 6 | export default function BlogIndex() { 7 | const posts = getPosts(); 8 | return posts 9 | ? ( 10 | <> 11 | 12 | 13 | 14 |
    15 | {Object.entries(posts).map(([id, post]) => ( 16 |
  • 17 | {post.title} 18 |
  • 19 | ))} 20 |
21 | 22 | ) 23 | :
Loading posts...
; 24 | } 25 | -------------------------------------------------------------------------------- /example/routes/blog/main.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from "react"; 2 | import { Outlet } from "react-router-dom"; 3 | import { DefaultErrorFallback, ErrorBoundary, Helmet } from "@udibo/react-app"; 4 | 5 | import { Loading } from "../../components/loading.tsx"; 6 | 7 | export const boundary = "/blog"; 8 | 9 | export default function Blog() { 10 | return ( 11 | <> 12 | 13 | 14 | 15 |

Blog

16 | }> 17 | 21 | 22 | 23 | 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /example/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { Helmet } from "@udibo/react-app"; 2 | 3 | export default function Index() { 4 | return ( 5 | <> 6 | 7 | Home 8 | 12 | 13 |

Welcome to Udibo React App

14 |

15 | Learn how to get started{" "} 16 | here. 17 |

18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /example/routes/main.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "@udibo/react-app/server"; 2 | import * as log from "@std/log"; 3 | 4 | export default new Router() 5 | .use(async (context, next) => { 6 | const { request, response } = context; 7 | const start = Date.now(); 8 | try { 9 | await next(); 10 | } finally { 11 | const responseTime = Date.now() - start; 12 | response.headers.set("X-Response-Time", `${responseTime}ms`); 13 | log.info( 14 | `${request.method} ${request.url.href}`, 15 | { status: response.status, responseTime }, 16 | ); 17 | } 18 | }); 19 | -------------------------------------------------------------------------------- /example/routes/main.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from "react"; 2 | import { Link, Outlet } from "react-router-dom"; 3 | import { DefaultErrorFallback, ErrorBoundary, Helmet } from "@udibo/react-app"; 4 | import "../log.ts"; 5 | 6 | import { Loading } from "../components/loading.tsx"; 7 | 8 | const navLinks = [ 9 | { label: "Home", to: "/" }, 10 | { label: "About", to: "/about" }, 11 | { label: "Blog", to: "/blog" }, 12 | { label: "Fake", to: "/fake" }, 13 | ]; 14 | 15 | export default function Main() { 16 | return ( 17 | <> 18 | 23 | 24 | 25 | 26 |
    27 | {navLinks.map((link) => ( 28 |
  • 29 | {link.label} 30 |
  • 31 | ))} 32 |
33 | }> 34 | 35 | 36 | 37 | 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /example/services/posts.ts: -------------------------------------------------------------------------------- 1 | import { HttpError } from "@udibo/react-app"; 2 | 3 | import { posts } from "../data/posts.ts"; 4 | import type { Post } from "../models/posts.ts"; 5 | 6 | export function getPosts(): { [id: number]: Post } { 7 | return { 8 | ...posts, 9 | 2: { 10 | id: 2, 11 | title: "Fake post", 12 | content: "This post does not exist.", 13 | }, 14 | }; 15 | } 16 | 17 | export function getPost(id: number) { 18 | const post = posts[id]; 19 | if (!post) throw new HttpError(404, "Not found"); 20 | return post; 21 | } 22 | -------------------------------------------------------------------------------- /example/services/posts.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { 3 | ErrorResponse, 4 | HttpError, 5 | isErrorResponse, 6 | useInitialState, 7 | } from "@udibo/react-app"; 8 | 9 | import type { Post, PostsState } from "../models/posts.ts"; 10 | 11 | const parseResponse = async (response: Response) => { 12 | let data; 13 | try { 14 | data = await response.json(); 15 | } catch (cause) { 16 | throw new HttpError(response.status, "Invalid response", { cause }); 17 | } 18 | if (isErrorResponse(data)) throw ErrorResponse.toError(data); 19 | if (response.status >= 400) { 20 | throw new HttpError(response.status, "Invalid response"); 21 | } 22 | return data; 23 | }; 24 | 25 | export function getPosts(): { [id: number]: Post } | null { 26 | const [error, setError] = useState(null); 27 | if (error) throw error; 28 | 29 | const initialState = useInitialState(); 30 | const [posts, setPosts] = useState<{ [id: number]: Post } | null>( 31 | initialState.posts ?? null, 32 | ); 33 | 34 | useEffect(() => { 35 | fetch("/api/blog/posts") 36 | .then(parseResponse) 37 | .then((posts: Post[]) => { 38 | setPosts(posts); 39 | setError(null); 40 | }) 41 | .catch((error: unknown) => { 42 | setPosts(null); 43 | setError(HttpError.from(error)); 44 | }); 45 | 46 | return () => { 47 | delete initialState.posts; 48 | setPosts(null); 49 | setError(null); 50 | }; 51 | }, []); 52 | 53 | return posts; 54 | } 55 | 56 | export function getPost(id: number): Post | null { 57 | const [error, setError] = useState(null); 58 | if (error) throw error; 59 | 60 | const initialState = useInitialState(); 61 | const [post, setPost] = useState( 62 | initialState.posts?.[id] ?? null, 63 | ); 64 | 65 | useEffect(() => { 66 | fetch(`/api/blog/posts/${id}`) 67 | .then(parseResponse) 68 | .then((post: Post) => { 69 | setPost(post); 70 | setError(null); 71 | }) 72 | .catch((error: unknown) => { 73 | setPost(null); 74 | setError(HttpError.from(error)); 75 | }); 76 | 77 | return () => { 78 | delete initialState.posts; 79 | setPost(null); 80 | setError(null); 81 | }; 82 | }, []); 83 | 84 | return post; 85 | } 86 | -------------------------------------------------------------------------------- /example/test-utils.tsx: -------------------------------------------------------------------------------- 1 | import "@udibo/react-app/global-jsdom"; 2 | import { 3 | cleanup, 4 | render as _render, 5 | type RenderOptions, 6 | } from "@testing-library/react"; 7 | export * from "@testing-library/react"; 8 | 9 | export function render( 10 | ui: React.ReactElement, 11 | options?: Omit, 12 | ): ReturnType & Disposable { 13 | const result = _render(ui, options); 14 | return { 15 | ...result, 16 | [Symbol.dispose]() { 17 | cleanup(); 18 | }, 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /global-jsdom.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This module is used to setup the jsdom environment for the tests. 3 | * This module should be imported before importing react testing library. 4 | * If you do not import this file before importing react testing library, 5 | * you will get an error when calling any of the screen functions. 6 | * 7 | * ```ts 8 | * import { assertEquals } from "@std/assert"; 9 | * import { describe, it } from "@std/testing/bdd"; 10 | * import "@udibo/react-app/global-jsdom"; 11 | * import { cleanup, render, screen } from "@testing-library/react"; 12 | * 13 | * const loadingTests = describe({ 14 | * name: "Loading", 15 | * afterEach() { 16 | * cleanup(); 17 | * }, 18 | * }); 19 | * 20 | * it(loadingTests, "renders loading message", () => { 21 | * render(); 22 | * assertEquals(screen.getByText("Loading...").textContent, "Loading..."); 23 | * }); 24 | * ``` 25 | * 26 | * If the default jsdom options do not work for your usecase, you can create your own `global-jsdom.ts` file to use instead of this module. 27 | * Just replace the html and options arguments in the following example with your own values. 28 | * 29 | * ```ts 30 | * import jsdom from "global-jsdom"; 31 | * jsdom(html, options); 32 | * ``` 33 | * 34 | * Then in your test files, do `import "./global-jsdom.js";` instead of `import "@udibo/react-app/global-jsdom";` before importing react testing library. 35 | * The path to the `global-jsdom.js` file should be relative to the test file. 36 | * 37 | * @module 38 | */ 39 | 40 | import jsdom from "global-jsdom"; 41 | jsdom(); 42 | -------------------------------------------------------------------------------- /log.ts: -------------------------------------------------------------------------------- 1 | import { getLogger as _getLogger } from "@std/log"; 2 | import type { Logger } from "@std/log"; 3 | 4 | /** 5 | * Gets the standard logger for the Udibo React App framework. 6 | * @returns The logger instance. 7 | */ 8 | export function getLogger(): Logger { 9 | return _getLogger("react-app"); 10 | } 11 | -------------------------------------------------------------------------------- /mod.test.tsx: -------------------------------------------------------------------------------- 1 | import "./mod.tsx"; 2 | -------------------------------------------------------------------------------- /mod.tsx: -------------------------------------------------------------------------------- 1 | /** @jsxRuntime automatic */ 2 | /** @jsxImportSource npm:react@18 */ 3 | /** @jsxImportSourceTypes npm:@types/react@18 */ 4 | import { useContext } from "react"; 5 | import * as reactHelmetAsync from "react-helmet-async"; 6 | const reactHelmetAsyncFixed = reactHelmetAsync; 7 | const helmet = reactHelmetAsyncFixed.default ?? 8 | reactHelmetAsync; 9 | export const Helmet = helmet.Helmet; 10 | import type { LogRecord } from "@std/log"; 11 | import { HttpError } from "./error.tsx"; 12 | 13 | export { 14 | getEnvironment, 15 | isBrowser, 16 | isDevelopment, 17 | isProduction, 18 | isServer, 19 | isTest, 20 | } from "./env.ts"; 21 | import { InitialStateContext } from "./context.ts"; 22 | export { HttpError, withErrorBoundary } from "./error.tsx"; 23 | export type { ErrorBoundaryProps, FallbackProps } from "./error.tsx"; 24 | export { 25 | DefaultErrorFallback, 26 | ErrorBoundary, 27 | ErrorResponse, 28 | isErrorResponse, 29 | isHttpError, 30 | NotFound, 31 | useAutoReset, 32 | } from "./error.tsx"; 33 | export type { HttpErrorOptions } from "./error.tsx"; 34 | 35 | /** 36 | * Gets the initial state of the application. 37 | * 38 | * @returns The initial state of the application. 39 | */ 40 | export function useInitialState< 41 | SharedState extends Record = Record< 42 | string | number, 43 | unknown 44 | >, 45 | >(): SharedState { 46 | return useContext(InitialStateContext) as SharedState; 47 | } 48 | 49 | /** 50 | * A log formatter function that can be used for formatting log messages from your application. 51 | * 52 | * With this formatter, you can log messages with additional context. 53 | * If there is an error, it should be passed as the argument after the message. 54 | * The last argument should be an object that contains additional context. 55 | * All internal log messages are generated using that call signature style. 56 | * 57 | * Below is an example of how to use this formatter with the `@std/log` module: 58 | * ```ts 59 | * import { HttpError, logFormatter } from "@udibo/react-app"; 60 | * import * as log from "@std/log"; 61 | * 62 | * const level = isDevelopment() ? "DEBUG" : "INFO"; 63 | * log.setup({ 64 | * handlers: { 65 | * default: new log.ConsoleHandler(level, { 66 | * formatter: logFormatter, 67 | * }), 68 | * }, 69 | * loggers: { "react-app": { level, handlers: ["default"] } }, 70 | * }); 71 | * 72 | * log.info("example", { a: 1, b: "x" }); 73 | * // 2024-07-28T23:48:02.435Z INFO: example {"a":1,"b":"x"} 74 | * log.warn("example", { a: 1, b: "x" }); 75 | * // 2024-07-28T23:48:02.435Z WARN: example {"a":1,"b":"x"} 76 | * const errors = [ 77 | * new Error("Something went wrong"), 78 | * new HttpError(500, "Something went wrong"), 79 | * ]; 80 | * log.error("regular error", errors[0], { a: 1, b: "x" }); 81 | * // 2024-07-28T23:48:02.435Z ERROR: regular error {"name":"Error","message":"Something went wrong","stack":"@http://localhost:9000/build/_main.js:1:10808\n"} {"a":1,"b":"x"} 82 | * log.error("http error", errors[1], { a: 1, b: "x" }); 83 | * // 2024-07-28T23:48:02.435Z ERROR: http error {"name":"InternalServerError","message":"Something went wrong","status":500,"expose":false,"stack":"_HttpError@http://localhost:9000/build/chunk-6ZBSCUFP.js:160:9589\n@http://localhost:9000/build/_main.js:1:10842\n"} {"a":1,"b":"x"} 84 | * log.critical("regular error", errors[0], { a: 1, b: "x" }); 85 | * // 2024-07-28T23:48:02.435Z CRITICAL: regular error {"name":"Error","message":"Something went wrong","stack":"@http://localhost:9000/build/_main.js:1:10808\n"} {"a":1,"b":"x"} 86 | * log.critical("http error", errors[1], { a: 1, b: "x" }); 87 | * // 2024-07-28T23:48:02.435Z CRITICAL: http error {"name":"InternalServerError","message":"Something went wrong","status":500,"expose":false,"stack":"_HttpError@http://localhost:9000/build/chunk-6ZBSCUFP.js:160:9589\n@http://localhost:9000/build/_main.js:1:10842\n"} {"a":1,"b":"x"} 88 | * ``` 89 | * 90 | * @param logRecord A record of a log message. 91 | * @returns Formatted log message 92 | */ 93 | export function logFormatter(logRecord: LogRecord): string { 94 | const { msg, levelName, datetime, args } = logRecord; 95 | let message = `${datetime.toISOString()} ${levelName}: ${msg}`; 96 | let data = args[0]; 97 | if (data instanceof Error) { 98 | const error = data; 99 | data = args[1]; 100 | if (error instanceof HttpError) { 101 | const errorJSON = HttpError.json(error); 102 | errorJSON.expose = (error as HttpError)?.expose; 103 | if (!errorJSON.expose) { 104 | errorJSON.message = error.message; 105 | } 106 | errorJSON.stack = error.stack; 107 | message += ` ${JSON.stringify(errorJSON)}`; 108 | } else { 109 | const errorJSON = { 110 | name: error.name, 111 | message: error.message, 112 | stack: error.stack, 113 | }; 114 | message += ` ${JSON.stringify(errorJSON)}`; 115 | } 116 | } 117 | 118 | if (data) { 119 | message += ` ${JSON.stringify(data)}`; 120 | } 121 | 122 | return message; 123 | } 124 | -------------------------------------------------------------------------------- /react.d.ts: -------------------------------------------------------------------------------- 1 | declare module "react" { 2 | // @ts-types="@types/react" 3 | import React from "npm:react@18"; 4 | export = React; 5 | } 6 | -------------------------------------------------------------------------------- /server.test.ts: -------------------------------------------------------------------------------- 1 | import "./server.tsx"; 2 | -------------------------------------------------------------------------------- /server.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * This module provides the server-side functionality for the Udibo React App framework. 3 | * It has utilities for creating the server, rendering the application, and handling errors. 4 | * 5 | * @module 6 | */ 7 | /** @jsxRuntime automatic */ 8 | /** @jsxImportSource npm:react@18 */ 9 | /** @jsxImportSourceTypes npm:@types/react@18 */ 10 | import * as path from "@std/path"; 11 | import * as oak from "@oak/oak"; 12 | import { StrictMode } from "react"; 13 | import reactHelmetAsync from "react-helmet-async"; 14 | const { HelmetProvider } = reactHelmetAsync; 15 | import type { HelmetServerState } from "react-helmet-async"; 16 | import { renderToReadableStream } from "react-dom/server"; 17 | import { 18 | createStaticHandler, 19 | createStaticRouter, 20 | StaticRouterProvider, 21 | } from "react-router-dom/server.js"; 22 | import type { StaticHandlerContext } from "react-router-dom/server.js"; 23 | import type { RouteObject } from "react-router-dom"; 24 | import serialize from "serialize-javascript"; 25 | 26 | import { ErrorResponse, HttpError, isHttpError } from "./error.tsx"; 27 | import { getEnvironment, isBrowser, isDevelopment, isTest } from "./env.ts"; 28 | import type { RouteFile } from "./client.tsx"; 29 | import { ErrorContext, InitialStateContext } from "./context.ts"; 30 | import { getLogger } from "./log.ts"; 31 | 32 | if (isBrowser()) { 33 | throw new Error("Cannot import server.tsx in the browser."); 34 | } 35 | 36 | /** 37 | * An interface that defines the configuration for generating the HTML that wraps the server-side rendered React application. 38 | */ 39 | interface HTMLOptions< 40 | SharedState extends Record = Record< 41 | string | number, 42 | unknown 43 | >, 44 | > { 45 | helmet: HelmetServerState; 46 | initialState: SharedState; 47 | error?: HttpError<{ boundary?: string }>; 48 | devPort?: number; 49 | } 50 | 51 | /** 52 | * Generates the HTML that wraps the server-side rendered React application. 53 | * 54 | * @param options - An object containing the configuration for generating the HTML that wraps the server-side rendered React application. 55 | * @returns An object containing the start and end of the HTML that wraps the server-side rendered React application. 56 | */ 57 | function html< 58 | SharedState extends Record = Record< 59 | string | number, 60 | unknown 61 | >, 62 | >( 63 | options: HTMLOptions, 64 | ): { start: string; end: string } { 65 | const { helmet, initialState, devPort, error } = options; 66 | const errorJSON = HttpError.json(error); 67 | if (isDevelopment()) { 68 | if (error?.expose) errorJSON.expose = error.expose; 69 | if (error instanceof Error) { 70 | errorJSON.stack = error.stack; 71 | } 72 | } 73 | 74 | const headLines = [ 75 | helmet.base.toString(), 76 | helmet.title.toString(), 77 | helmet.priority.toString(), 78 | helmet.meta.toString(), 79 | helmet.link.toString(), 80 | helmet.style.toString(), 81 | helmet.script.toString(), 82 | ``, 92 | helmet.noscript.toString(), 93 | ].filter((tag) => Boolean(tag)); 94 | 95 | return { 96 | start: `\ 97 | 98 | 99 | 100 | ${headLines.join("\n ")} 101 | 104 | 105 | 106 |
`, 107 | end: ` 108 |
109 | 110 | 111 | `, 112 | }; 113 | } 114 | 115 | /** 116 | * Gets the fetch request object from an Oak request object. This is used by react router to assist with server-side rendering. 117 | */ 118 | function getFetchRequest( 119 | request: oak.Request, 120 | response: oak.Response, 121 | ): Request { 122 | const { url, headers, method, body, source } = request; 123 | // Source is available on Deno, but not in other JavaScript runtimes. 124 | if (source) return source; 125 | 126 | const controller = new AbortController(); 127 | response.addResource({ close: () => controller.abort() }); 128 | 129 | const init: RequestInit = { 130 | method, 131 | headers, 132 | signal: controller.signal, 133 | }; 134 | 135 | if (method !== "GET" && method !== "HEAD" && body) { 136 | init.body = request.body.stream; 137 | } 138 | 139 | return new Request(url.href, init); 140 | } 141 | 142 | /** 143 | * Options for rendering the React application to a readable stream that can be returned to the client. 144 | */ 145 | interface RenderOptions { 146 | /** 147 | * The React Router route for the application. 148 | * The build script will automatically generate this for your application's routes. 149 | * The route object is the default export from the `_main.tsx` file in the routes directory. 150 | */ 151 | route: RouteObject; 152 | /** Used to perform data fetching and submissions on the server. */ 153 | handler: ReturnType; 154 | /** 155 | * If an error occurs when handling the request, this property will be set to that error. 156 | * The error will be serialized and sent to the browser. 157 | * The browser will recreate the error for an `ErrorBoundary` to catch. 158 | * If the server error is not getting caught, the boundary doesn't match the `ErrorBoundary` you expect to catch it. 159 | */ 160 | error?: HttpError<{ boundary?: string }>; 161 | /** The port for the dev task's live reload server. */ 162 | devPort?: number; 163 | } 164 | 165 | /** 166 | * Renders the React application to a readable stream that can be returned to the client. 167 | * 168 | * @param context - The server context object for the request. 169 | * @returns A readable stream that can be returned to the client. 170 | */ 171 | async function renderAppToReadableStream< 172 | SharedState extends Record = Record< 173 | string | number, 174 | unknown 175 | >, 176 | >( 177 | context: Context, 178 | options: RenderOptions, 179 | ) { 180 | const { request, response, state } = context; 181 | const { handler, error, devPort } = options; 182 | const { initialState } = state.app; 183 | const helmetContext = {} as { helmet: HelmetServerState }; 184 | 185 | const fetchRequest = getFetchRequest(request, response); 186 | const routerContext = await handler.query( 187 | fetchRequest, 188 | ) as StaticHandlerContext; 189 | 190 | const router = createStaticRouter(handler.dataRoutes, routerContext); 191 | 192 | const stream = await renderToReadableStream( 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | , 202 | { 203 | onError(error: unknown) { 204 | getLogger().error("renderToReadableStream error", error); 205 | }, 206 | }, 207 | ); 208 | await stream.allReady; 209 | 210 | const { start, end } = html({ 211 | helmet: helmetContext.helmet, 212 | initialState, 213 | error, 214 | devPort, 215 | }); 216 | 217 | const encoder = new TextEncoder(); 218 | return stream 219 | .pipeThrough( 220 | new TransformStream({ 221 | start(controller) { 222 | controller.enqueue(encoder.encode(start)); 223 | }, 224 | flush(controller) { 225 | controller.enqueue(encoder.encode(end)); 226 | }, 227 | }), 228 | ); 229 | } 230 | 231 | /** 232 | * The application's state on the server when handling a request. 233 | */ 234 | export interface State< 235 | /** The initial state that is used to render the application. */ 236 | SharedState extends Record = Record< 237 | string | number, 238 | unknown 239 | >, 240 | > { 241 | /** 242 | * The initial state that is used to render the application. 243 | * It will be serialized and sent to the browser. 244 | * These initialState can be accessed from the React application using `useInitialState`. 245 | */ 246 | initialState: SharedState; 247 | /** A function that renders the application to a readable stream and responds to the request with it. */ 248 | render: () => Promise; 249 | } 250 | 251 | /** 252 | * An interface for registering middleware that will run when certain HTTP methods and paths are requested. 253 | * as well as provides a way to parameterize paths of the requested paths. 254 | * This should be used for routers that could render the application. 255 | */ 256 | export class Router< 257 | /** The initial state that is used to render the application. */ 258 | SharedState extends Record = Record< 259 | string | number, 260 | unknown 261 | >, 262 | RouterState extends oak.State = Record, 263 | > extends oak.Router }> {} 264 | 265 | /** 266 | * Provides context about the current request and response to middleware functions. 267 | * This should be used when in handlers that could render the application. 268 | */ 269 | export type Context< 270 | SharedState extends Record = Record< 271 | string | number, 272 | unknown 273 | >, 274 | ContextState extends ApplicationState = oak.State, 275 | ApplicationState extends oak.State = Record, 276 | > = oak.Context< 277 | ContextState & { app: State }, 278 | ApplicationState 279 | >; 280 | 281 | /** The options for creating the application. */ 282 | export interface ApplicationOptions< 283 | SharedState extends Record = Record< 284 | string | number, 285 | unknown 286 | >, 287 | ApplicationState extends oak.State = Record, 288 | > { 289 | /** 290 | * The application's default initial state. 291 | * The initial state will be serialized and sent to the browser. 292 | * Data can be added to it before the application is rendered. 293 | * These initialState can be accessed from the React application using `useInitialState`. 294 | */ 295 | initialState?: SharedState; 296 | /** 297 | * The React Router route for the application. 298 | * The build script will automatically generate this for your application's routes. 299 | * The route object is the default export from the `_main.tsx` file in the routes directory. 300 | */ 301 | route: RouteObject; 302 | /** 303 | * The Oak router for the application. 304 | * The build script will automatically generate this for your application's routes. 305 | * The router object is the default export from the `_main.ts` file in the routes directory. 306 | */ 307 | router: Router; 308 | /** 309 | * The working directory of the application. 310 | * Defaults to the current working directory that the application is running from. 311 | */ 312 | workingDirectory?: string; 313 | /** The port for the dev task's live reload server. Defaults to 9001. */ 314 | devPort?: number; 315 | } 316 | 317 | const TRAILING_SLASHES = /\/+$/; 318 | 319 | /** An application server. */ 320 | export class Application< 321 | SharedState extends Record = Record< 322 | string | number, 323 | unknown 324 | >, 325 | ApplicationState extends oak.State = Record, 326 | > extends oak.Application }> { 327 | constructor(options: ApplicationOptions) { 328 | const { 329 | initialState, 330 | route, 331 | router, 332 | workingDirectory: _workingDirectory, 333 | devPort, 334 | ...superOptions 335 | } = options; 336 | super(superOptions); 337 | const workingDirectory = _workingDirectory ?? Deno.cwd(); 338 | const handler = createStaticHandler([route]); 339 | 340 | const appRouter = new Router() 341 | .use(async (context, next) => { 342 | const { request, response } = context; 343 | const { pathname, search } = request.url; 344 | if (pathname.length > 1 && pathname.at(-1) === "/") { 345 | response.status = 301; 346 | response.redirect(pathname.replace(TRAILING_SLASHES, "") + search); 347 | } else { 348 | await next(); 349 | } 350 | }) 351 | .use(async (context: Context, next) => { 352 | const { response, state } = context; 353 | let error: HttpError | undefined = undefined; 354 | try { 355 | if (!state.app) { 356 | state.app = { 357 | initialState: structuredClone(initialState) ?? {} as SharedState, 358 | render: async () => { 359 | response.type = "html"; 360 | response.body = await renderAppToReadableStream!(context, { 361 | route, 362 | handler, 363 | error, 364 | devPort, 365 | }); 366 | }, 367 | }; 368 | } 369 | await next(); 370 | } catch (cause) { 371 | error = HttpError.from(cause); 372 | getLogger().error("UI route error", error); 373 | 374 | response.status = error.status; 375 | await state.app.render(); 376 | } 377 | }) 378 | .use(router.routes(), router.allowedMethods()); 379 | 380 | appRouter.get("/(.*)", async (context) => { 381 | try { 382 | await context.send({ root: `${workingDirectory}/public` }); 383 | } catch (cause) { 384 | if (isHttpError(cause) && cause.status === oak.Status.NotFound) { 385 | throw new HttpError(404, "Not found", { cause }); 386 | } else { 387 | throw cause; 388 | } 389 | } 390 | }); 391 | 392 | this.use(appRouter.routes(), appRouter.allowedMethods()); 393 | } 394 | } 395 | 396 | /** 397 | * This function tells the dev server when the app server is listening. 398 | * If you are not using serve, you must add an event listener to your app that will call this function once it's listening. 399 | * If this function is not called, the browser will not automatically refresh when the app server is restarted. 400 | * If called before the app server is listening, the browser will refresh before the app server is ready to handle the request. 401 | * This function will not do anything if the app is not running in development mode. 402 | * 403 | * ```ts 404 | * app.addEventListener("listen", ({ hostname, port, secure }) => { 405 | * const origin = `${secure ? "https://" : "http://"}${hostname}`; 406 | * getLogger().info(`Listening on: ${origin}:${port}`, { 407 | * hostname, 408 | * port, 409 | * secure, 410 | * }); 411 | * queueMicrotask(() => 412 | * listeningDev({ hostname, secure }) 413 | * ); 414 | * }); 415 | * ``` 416 | */ 417 | export async function listeningDev( 418 | { hostname, secure, devPort }: { 419 | hostname: string; 420 | secure: boolean; 421 | devPort?: number; 422 | }, 423 | ) { 424 | if (isDevelopment()) { 425 | try { 426 | const origin = `${secure ? "https://" : "http://"}${hostname}`; 427 | await fetch(`${origin}:${devPort || 9001}/listening`); 428 | } catch { 429 | // Ignore errors 430 | } 431 | } 432 | } 433 | 434 | /** The options to create and start an application server. */ 435 | export type ServeOptions< 436 | SharedState extends Record = Record< 437 | string | number, 438 | unknown 439 | >, 440 | > = ApplicationOptions & oak.ListenOptions; 441 | 442 | /** Creates and starts an application server. */ 443 | export async function serve< 444 | SharedState extends Record = Record< 445 | string | number, 446 | unknown 447 | >, 448 | >(options: ServeOptions) { 449 | const { port, hostname, secure, signal, ...appOptions } = options; 450 | const app = new Application(appOptions); 451 | 452 | app.addEventListener("error", ({ error }) => { 453 | getLogger().error("Uncaught application error", error); 454 | }); 455 | 456 | app.addEventListener("listen", ({ hostname, port, secure }) => { 457 | const origin = `${secure ? "https://" : "http://"}${hostname}`; 458 | getLogger().info(`Listening on: ${origin}:${port}`, { 459 | hostname, 460 | port, 461 | secure, 462 | }); 463 | queueMicrotask(() => 464 | listeningDev({ hostname, secure, devPort: options.devPort }) 465 | ); 466 | }); 467 | 468 | await app.listen({ port, hostname, secure, signal } as oak.ListenOptions); 469 | } 470 | 471 | /** 472 | * This middleware ensures all errors in the route are HttpError objects. 473 | * If an error isn't an HttpError, a new HttpError is created with the original error as the cause. 474 | * If a boundary is specified, it will add the boundary to the HttpError. 475 | * If an ErrorBoundary exists with a matching boundary, it will be used to handle the error. 476 | * If a boundary is not specified, the first ErrorBoundary without a boundary specified will handle the error. 477 | * If a boundary is specified, but no ErrorBoundary exists with a matching boundary, the error will go unhandled. 478 | * 479 | * By default, any route that has an ErrorFallback will have an errorBoundary automatically added to it. 480 | * The automatic error boundaries name will match the route by default. 481 | * If a route exports a boundary string, that will be used as the errorBoundary's boundary. 482 | * You can add your own error boundaries anywhere. 483 | * 484 | * To ensure an error boundary catches the error, you need to either export a boundary string from your route 485 | * or your router needs to use the error boundary middleware. 486 | * 487 | * ```ts 488 | * export const boundary = "MyComponentErrorBoundary" 489 | * ``` 490 | * 491 | * ```ts 492 | * const router = new Router() 493 | * .use(errorBoundary("MyComponentErrorBoundary")) 494 | * ``` 495 | * 496 | * Then the related react component for the route needs to either use `withErrorBoundary` or `ErrorBoundary` to be able to catch the error during rendering. 497 | * The boundary identifier must match the one on the server. 498 | * 499 | * ```tsx 500 | * const MyComponentSafe = withErrorBoundary(MyComponent, { 501 | * FallbackComponent: DefaultErrorFallback, 502 | * boundary: "MyComponentErrorBoundary" 503 | * }) 504 | * ``` 505 | * 506 | * ```tsx 507 | * 508 | * 509 | * 510 | * ``` 511 | */ 512 | export function errorBoundary< 513 | RouteParams extends oak.RouteParams = oak.RouteParams, 514 | /** The initial state that is used to render the application. */ 515 | SharedState extends Record = Record< 516 | string | number, 517 | unknown 518 | >, 519 | RouterState extends oak.State = Record, 520 | >( 521 | boundary?: string, 522 | ): oak.RouterMiddleware< 523 | string, 524 | RouteParams, 525 | RouterState & { app: State } 526 | > { 527 | return async (_context, next) => { 528 | try { 529 | await next(); 530 | } catch (cause) { 531 | const error = HttpError.from<{ boundary?: string }>(cause); 532 | if (isDevelopment()) error.expose = true; 533 | if (boundary) error.data.boundary = boundary; 534 | throw error; 535 | } 536 | }; 537 | } 538 | 539 | /** 540 | * An Oak router that is used to render the application for get requests. 541 | * It is used for all route components that do not have their own route middleware. 542 | * The defaultRouter is not meant to be used directly by the user and is for internal use only. 543 | */ 544 | const defaultRouter = new Router() 545 | .get("/", async (context: Context) => { 546 | await context.state.app.render(); 547 | }); 548 | 549 | /** 550 | * A representation of the routers for a routes directory. 551 | * This interface is meant for internal use only. 552 | */ 553 | export interface RouterDefinition< 554 | SharedState extends Record = Record< 555 | string | number, 556 | unknown 557 | >, 558 | > { 559 | name: string; 560 | parent?: RouterDefinition; 561 | react?: boolean; 562 | file?: { 563 | react?: RouteFile; 564 | oak?: Router; 565 | }; 566 | main?: { 567 | react?: RouteFile; 568 | oak?: Router; 569 | }; 570 | index?: { 571 | react?: RouteFile; 572 | oak?: Router; 573 | }; 574 | children?: Record>; 575 | } 576 | 577 | export const ROUTE_PARAM = /^\[(.+)]$/; 578 | export const ROUTE_WILDCARD = /^\[\.\.\.\]$/; 579 | export function routePathFromName(name: string, forServer = false): string { 580 | if (!name) return ""; 581 | return name 582 | .replace(ROUTE_WILDCARD, forServer ? "(.*)" : "*") 583 | .replace(ROUTE_PARAM, ":$1"); 584 | } 585 | export function routerPathFromName(name: string): string { 586 | return routePathFromName(name, true); 587 | } 588 | 589 | /** 590 | * Generates an Oak router for a routes directory. 591 | * The router returned by this function is the default export from the `_main.ts` file in the routes directory. 592 | * This function is meant for internal use only. 593 | */ 594 | export function generateRouter< 595 | SharedState extends Record = Record< 596 | string | number, 597 | unknown 598 | >, 599 | >( 600 | options: RouterDefinition, 601 | relativePath?: string, 602 | parentBoundary?: string, 603 | ): Router { 604 | const { name, react, file, main, index, children, parent } = options; 605 | 606 | const router = new Router(); 607 | if (parent?.react && !react) { 608 | router.use(async ({ request, response }, next) => { 609 | try { 610 | await next(); 611 | } catch (cause) { 612 | const error = HttpError.from(cause); 613 | getLogger().error("API route error", error); 614 | 615 | response.status = error.status; 616 | const extname = path.extname(request.url.pathname); 617 | if (error.status !== 404 || extname === "") { 618 | response.body = new ErrorResponse(error); 619 | } 620 | } 621 | }); 622 | } 623 | 624 | const currentPath = `${ 625 | relativePath && relativePath !== "/" ? relativePath : "" 626 | }/${name}`; 627 | 628 | let boundary = parentBoundary; 629 | if (file) { 630 | if (file.react && (file.react.ErrorFallback || file.react.boundary)) { 631 | boundary = file.react.boundary ?? currentPath; 632 | } 633 | 634 | const boundaryMiddleware = react && errorBoundary(boundary); 635 | 636 | if (file.oak) { 637 | if (boundaryMiddleware) router.use(boundaryMiddleware); 638 | router.use(file.oak.routes(), file.oak.allowedMethods()); 639 | } else if (file.react) { 640 | if (boundaryMiddleware) router.use(boundaryMiddleware); 641 | router.use(defaultRouter.routes(), defaultRouter.allowedMethods()); 642 | } 643 | } else { 644 | if (main?.react && (main.react.ErrorFallback || main.react.boundary)) { 645 | boundary = main.react.boundary ?? currentPath; 646 | } 647 | const boundaryMiddleware = react && errorBoundary(boundary); 648 | if (boundaryMiddleware) router.use(boundaryMiddleware); 649 | 650 | const mainRouter = main?.oak ?? router; 651 | 652 | if (index) { 653 | if (index.react && (index.react.ErrorFallback || index.react.boundary)) { 654 | mainRouter.use( 655 | errorBoundary( 656 | index.react.boundary ?? `${currentPath}/index`, 657 | ), 658 | ); 659 | } else if (main?.oak && boundaryMiddleware) { 660 | mainRouter.use(boundaryMiddleware); 661 | } 662 | 663 | if (index.oak) { 664 | mainRouter.use("/", index.oak.routes(), index.oak.allowedMethods()); 665 | } else if (react) { 666 | mainRouter.use( 667 | "/", 668 | defaultRouter.routes(), 669 | defaultRouter.allowedMethods(), 670 | ); 671 | } 672 | 673 | if (boundaryMiddleware) mainRouter.use(boundaryMiddleware); 674 | } 675 | 676 | if (children) { 677 | let notFoundRouter: Router | undefined = undefined; 678 | for (const [name, child] of Object.entries(children)) { 679 | child.parent = options; 680 | const childRouter = generateRouter( 681 | child, 682 | currentPath, 683 | boundary, 684 | ); 685 | if (name === "[...]") { 686 | notFoundRouter = childRouter; 687 | } else { 688 | mainRouter.use( 689 | `/${routerPathFromName(name)}`, 690 | childRouter.routes(), 691 | childRouter.allowedMethods(), 692 | ); 693 | } 694 | } 695 | 696 | if (notFoundRouter) { 697 | mainRouter.use( 698 | `/${routerPathFromName("[...]")}`, 699 | notFoundRouter.routes(), 700 | notFoundRouter.allowedMethods(), 701 | ); 702 | } 703 | } 704 | 705 | if (main?.oak) { 706 | router.use(mainRouter.routes(), mainRouter.allowedMethods()); 707 | } 708 | } 709 | 710 | return router; 711 | } 712 | -------------------------------------------------------------------------------- /test-utils.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "@std/assert"; 2 | import { describe, it } from "@std/testing/bdd"; 3 | 4 | import { 5 | omitEnvironmentKeys, 6 | startBrowser, 7 | startEnvironment, 8 | } from "./test-utils.ts"; 9 | import { 10 | getEnvironment, 11 | isBrowser, 12 | isDevelopment, 13 | isProduction, 14 | isServer, 15 | isTest, 16 | } from "./env.ts"; 17 | import type { AppWindow } from "./env.ts"; 18 | 19 | const startBrowserTests = describe("startBrowser"); 20 | 21 | it(startBrowserTests, "without arguments", () => { 22 | // This code is running on the server. 23 | assertEquals(isBrowser(), false); 24 | assertEquals(isServer(), true); 25 | assertEquals(getEnvironment(), "test"); 26 | assertEquals(isDevelopment(), false); 27 | assertEquals(isProduction(), false); 28 | assertEquals(isTest(), true); 29 | 30 | // There is no app in the current environment. 31 | assertEquals((globalThis.window as AppWindow)?.app, undefined); 32 | 33 | // Simulate a new browser environment. 34 | const browser = startBrowser(); 35 | try { 36 | // It now looks like the code is running in the browser. 37 | assertEquals(isBrowser(), true); 38 | assertEquals(isServer(), false); 39 | assertEquals(getEnvironment(), "test"); 40 | assertEquals(isDevelopment(), false); 41 | assertEquals(isProduction(), false); 42 | assertEquals(isTest(), true); 43 | 44 | // The JSON representation of the app in the simulated browser environment. 45 | assertEquals((window as AppWindow).app, { 46 | env: "test", 47 | initialState: {}, 48 | }); 49 | } finally { 50 | // End the simulation. 51 | browser.end(); 52 | } 53 | 54 | // It no longer looks like the code is running in the browser. 55 | assertEquals(isBrowser(), false); 56 | assertEquals(isServer(), true); 57 | assertEquals(getEnvironment(), "test"); 58 | assertEquals(isDevelopment(), false); 59 | assertEquals(isProduction(), false); 60 | assertEquals(isTest(), true); 61 | 62 | // There is no app in the current environment. 63 | assertEquals((globalThis.window as AppWindow)?.app, undefined); 64 | }); 65 | 66 | it(startBrowserTests, "with app argument", () => { 67 | // This code is running on the server. 68 | assertEquals(isBrowser(), false); 69 | assertEquals(isServer(), true); 70 | assertEquals(getEnvironment(), "test"); 71 | assertEquals(isDevelopment(), false); 72 | assertEquals(isProduction(), false); 73 | assertEquals(isTest(), true); 74 | 75 | // There is no app in the current environment. 76 | assertEquals((globalThis.window as AppWindow)?.app, undefined); 77 | 78 | // Simulate a new browser environment in development mode. 79 | const browser = startBrowser({ 80 | env: "development", 81 | initialState: {}, 82 | }); 83 | try { 84 | // It now looks like the code is running in the browser. 85 | assertEquals(isBrowser(), true); 86 | assertEquals(isServer(), false); 87 | assertEquals(getEnvironment(), "development"); 88 | assertEquals(isDevelopment(), true); 89 | assertEquals(isProduction(), false); 90 | assertEquals(isTest(), false); 91 | 92 | // The JSON representation of the app in the simulated browser environment. 93 | assertEquals((window as AppWindow).app, { 94 | env: "development", 95 | initialState: {}, 96 | }); 97 | } finally { 98 | // End the simulation. 99 | browser.end(); 100 | } 101 | 102 | // It no longer looks like the code is running in the browser. 103 | assertEquals(isBrowser(), false); 104 | assertEquals(isServer(), true); 105 | assertEquals(getEnvironment(), "test"); 106 | assertEquals(isDevelopment(), false); 107 | assertEquals(isProduction(), false); 108 | assertEquals(isTest(), true); 109 | 110 | // There is no app in the current environment. 111 | assertEquals((globalThis.window as AppWindow)?.app, undefined); 112 | }); 113 | 114 | it(startBrowserTests, "is disposable", () => { 115 | // This code is running on the server. 116 | assertEquals(isBrowser(), false); 117 | assertEquals(isServer(), true); 118 | assertEquals(getEnvironment(), "test"); 119 | assertEquals(isDevelopment(), false); 120 | assertEquals(isProduction(), false); 121 | assertEquals(isTest(), true); 122 | 123 | // There is no app in the current environment. 124 | assertEquals((globalThis.window as AppWindow)?.app, undefined); 125 | 126 | // This function simulates a new browser environment until the function returns. 127 | function test() { 128 | // Simulate a new browser environment. 129 | using _browser = startBrowser({ 130 | env: "production", 131 | initialState: {}, 132 | }); 133 | 134 | // It now looks like the code is running in the browser. 135 | assertEquals(isBrowser(), true); 136 | assertEquals(isServer(), false); 137 | assertEquals(getEnvironment(), "production"); 138 | assertEquals(isDevelopment(), false); 139 | assertEquals(isProduction(), true); 140 | assertEquals(isTest(), false); 141 | 142 | // The JSON representation of the app in the simulated browser environment. 143 | assertEquals((window as AppWindow).app, { 144 | env: "production", 145 | initialState: {}, 146 | }); 147 | } 148 | // Invoking the test function will simulate the browser environment and undo any changes made to it when the function returns. 149 | test(); 150 | 151 | // It no longer looks like the code is running in the browser. 152 | assertEquals(isBrowser(), false); 153 | assertEquals(isServer(), true); 154 | assertEquals(getEnvironment(), "test"); 155 | assertEquals(isDevelopment(), false); 156 | assertEquals(isProduction(), false); 157 | assertEquals(isTest(), true); 158 | 159 | // There is no app in the current environment. 160 | assertEquals((globalThis.window as AppWindow)?.app, undefined); 161 | }); 162 | 163 | const startEnvironmentTests = describe({ 164 | name: "startEnvironment", 165 | beforeAll: () => { 166 | Deno.env.set("PASSWORD", "hunter2"); 167 | }, 168 | afterAll: () => { 169 | Deno.env.delete("PASSWORD"); 170 | }, 171 | }); 172 | 173 | it(startEnvironmentTests, "without arguments", () => { 174 | // Environment variables before simulating the environment. 175 | assertEquals(Deno.env.get("APP_ENV"), "test"); 176 | assertEquals(Deno.env.get("PASSWORD"), "hunter2"); 177 | assertEquals(Deno.env.get("EXAMPLE"), undefined); 178 | 179 | // Simulate a new environment based on the current environment. 180 | const environment = startEnvironment(); 181 | try { 182 | // Unchanged variables are still the same as before the simulation started. 183 | assertEquals(Deno.env.get("APP_ENV"), "test"); 184 | assertEquals(Deno.env.get("EXAMPLE"), undefined); 185 | 186 | // Environment variables can be changed. 187 | Deno.env.set("APP_ENV", "production"); 188 | assertEquals(Deno.env.get("APP_ENV"), "production"); 189 | Deno.env.set("PASSWORD", "qwerty"); 190 | assertEquals(Deno.env.get("PASSWORD"), "qwerty"); 191 | 192 | // New environment variables can be created. 193 | Deno.env.set("EXAMPLE", "example"); 194 | assertEquals(Deno.env.get("EXAMPLE"), "example"); 195 | } finally { 196 | // End the simulation. 197 | environment.end(); 198 | } 199 | 200 | // Environment variables are back to their original values. 201 | assertEquals(Deno.env.get("APP_ENV"), "test"); 202 | assertEquals(Deno.env.get("PASSWORD"), "hunter2"); 203 | assertEquals(Deno.env.get("EXAMPLE"), undefined); 204 | }); 205 | 206 | it(startEnvironmentTests, "with appEnvironment argument", () => { 207 | // Environment variables before simulating the environment 208 | assertEquals(Deno.env.get("APP_ENV"), "test"); 209 | assertEquals(Deno.env.get("PASSWORD"), "hunter2"); 210 | assertEquals(Deno.env.get("EXAMPLE"), undefined); 211 | 212 | // Simulate a new production environment based on the current environment. 213 | const environment = startEnvironment("production"); 214 | try { 215 | // The APP_ENV environment variable is now "production". 216 | assertEquals(Deno.env.get("APP_ENV"), "production"); 217 | 218 | // unchanged variables are still the same as before the simulation started. 219 | assertEquals(Deno.env.get("PASSWORD"), "hunter2"); 220 | 221 | // Environment variables can be changed. 222 | Deno.env.set("PASSWORD", "qwerty"); 223 | assertEquals(Deno.env.get("PASSWORD"), "qwerty"); 224 | 225 | // New environment variables can be created. 226 | Deno.env.set("EXAMPLE", "example"); 227 | assertEquals(Deno.env.get("EXAMPLE"), "example"); 228 | } finally { 229 | // End the simulation. 230 | environment.end(); 231 | } 232 | 233 | // Environment variables are back to their original values. 234 | assertEquals(Deno.env.get("APP_ENV"), "test"); 235 | assertEquals(Deno.env.get("PASSWORD"), "hunter2"); 236 | assertEquals(Deno.env.get("EXAMPLE"), undefined); 237 | }); 238 | 239 | it(startEnvironmentTests, "with environment argument", () => { 240 | // Environment variables before simulating the environment 241 | assertEquals(Deno.env.get("APP_ENV"), "test"); 242 | assertEquals(Deno.env.get("PASSWORD"), "hunter2"); 243 | assertEquals(Deno.env.get("EXAMPLE"), undefined); 244 | 245 | // Simulate a new production environment based on the current environment. 246 | const environment = startEnvironment({ 247 | APP_ENV: "production", 248 | PASSWORD: "qwerty", 249 | }); 250 | try { 251 | // The APP_ENV environment variable is now "production". 252 | assertEquals(Deno.env.get("APP_ENV"), "production"); 253 | 254 | // The PASSWORD environment variable is now "qwerty". 255 | assertEquals(Deno.env.get("PASSWORD"), "qwerty"); 256 | 257 | // Environment variables can be changed. 258 | Deno.env.set("PASSWORD", "123456"); 259 | assertEquals(Deno.env.get("PASSWORD"), "123456"); 260 | 261 | // New environment variables can be created. 262 | Deno.env.set("EXAMPLE", "example"); 263 | assertEquals(Deno.env.get("EXAMPLE"), "example"); 264 | } finally { 265 | environment.end(); 266 | } 267 | 268 | // Environment variables are back to their original values. 269 | assertEquals(Deno.env.get("APP_ENV"), "test"); 270 | assertEquals(Deno.env.get("PASSWORD"), "hunter2"); 271 | assertEquals(Deno.env.get("EXAMPLE"), undefined); 272 | }); 273 | 274 | it(startEnvironmentTests, "with all arguments", () => { 275 | // Environment variables before simulating the environment 276 | assertEquals(Deno.env.get("APP_ENV"), "test"); 277 | assertEquals(Deno.env.get("PASSWORD"), "hunter2"); 278 | assertEquals(Deno.env.get("EXAMPLE"), undefined); 279 | 280 | // Simulate a new production environment based on the current environment. 281 | const environment = startEnvironment("production", { PASSWORD: "qwerty" }); 282 | try { 283 | // The APP_ENV environment variable is now "production". 284 | assertEquals(Deno.env.get("APP_ENV"), "production"); 285 | 286 | // The PASSWORD environment variable is now "qwerty". 287 | assertEquals(Deno.env.get("PASSWORD"), "qwerty"); 288 | 289 | // Environment variables can be changed. 290 | Deno.env.set("PASSWORD", "123456"); 291 | assertEquals(Deno.env.get("PASSWORD"), "123456"); 292 | 293 | // New environment variables can be created. 294 | Deno.env.set("EXAMPLE", "example"); 295 | assertEquals(Deno.env.get("EXAMPLE"), "example"); 296 | } finally { 297 | environment.end(); 298 | } 299 | 300 | // Environment variables are back to their original values. 301 | assertEquals(Deno.env.get("APP_ENV"), "test"); 302 | assertEquals(Deno.env.get("PASSWORD"), "hunter2"); 303 | assertEquals(Deno.env.get("EXAMPLE"), undefined); 304 | }); 305 | 306 | it(startEnvironmentTests, "is disposable", () => { 307 | // Environment variables before simulating the environment 308 | assertEquals(Deno.env.get("APP_ENV"), "test"); 309 | assertEquals(Deno.env.get("PASSWORD"), "hunter2"); 310 | assertEquals(Deno.env.get("EXAMPLE"), undefined); 311 | 312 | // This function simulates a new production environment until the function returns. 313 | function test() { 314 | // Simulate a new production environment based on the current environment. 315 | using _environment = startEnvironment("production", { PASSWORD: "qwerty" }); 316 | 317 | // The APP_ENV environment variable is now "production". 318 | assertEquals(Deno.env.get("APP_ENV"), "production"); 319 | 320 | // The PASSWORD environment variable is now "qwerty". 321 | assertEquals(Deno.env.get("PASSWORD"), "qwerty"); 322 | 323 | // Environment variables can be changed. 324 | Deno.env.set("PASSWORD", "123456"); 325 | assertEquals(Deno.env.get("PASSWORD"), "123456"); 326 | 327 | // New environment variables can be created. 328 | Deno.env.set("EXAMPLE", "example"); 329 | assertEquals(Deno.env.get("EXAMPLE"), "example"); 330 | } 331 | // Invoking the test function will simulate the environment and undo any changes made to it when the function returns. 332 | test(); 333 | 334 | // Environment variables are back to their original values. 335 | assertEquals(Deno.env.get("APP_ENV"), "test"); 336 | assertEquals(Deno.env.get("PASSWORD"), "hunter2"); 337 | assertEquals(Deno.env.get("EXAMPLE"), undefined); 338 | }); 339 | 340 | const omitEnvironmentKeysTests = describe({ 341 | name: "omitEnvironmentKeys", 342 | beforeAll: () => { 343 | Deno.env.set("PASSWORD", "hunter2"); 344 | }, 345 | afterAll: () => { 346 | Deno.env.delete("PASSWORD"); 347 | }, 348 | }); 349 | 350 | it(omitEnvironmentKeysTests, "omits specified environment keys", () => { 351 | // Environment variables before simulating the environment. 352 | assertEquals(Deno.env.get("APP_ENV"), "test"); 353 | assertEquals(Deno.env.get("PASSWORD"), "hunter2"); 354 | assertEquals(Deno.env.get("EXAMPLE"), undefined); 355 | 356 | // Simulate a new environment based on the current environment without the "PASSWORD" environment variable. 357 | const environment = omitEnvironmentKeys(["PASSWORD"]); 358 | try { 359 | // Unspecified variables will still the same as before the simulation started. 360 | assertEquals(Deno.env.get("APP_ENV"), "test"); 361 | 362 | // The PASSWORD environment variable is now undefined. 363 | assertEquals(Deno.env.get("PASSWORD"), undefined); 364 | 365 | // Environment variables can be changed. 366 | Deno.env.set("APP_ENV", "production"); 367 | assertEquals(Deno.env.get("APP_ENV"), "production"); 368 | 369 | // New environment variables can be created. 370 | Deno.env.set("EXAMPLE", "example"); 371 | assertEquals(Deno.env.get("EXAMPLE"), "example"); 372 | } finally { 373 | // End the simulation. 374 | environment.end(); 375 | } 376 | 377 | // Environment variables are back to their original values. 378 | assertEquals(Deno.env.get("APP_ENV"), "test"); 379 | assertEquals(Deno.env.get("PASSWORD"), "hunter2"); 380 | assertEquals(Deno.env.get("EXAMPLE"), undefined); 381 | }); 382 | 383 | it(omitEnvironmentKeysTests, "is disposable", () => { 384 | // Environment variables before simulating the environment. 385 | assertEquals(Deno.env.get("APP_ENV"), "test"); 386 | assertEquals(Deno.env.get("PASSWORD"), "hunter2"); 387 | assertEquals(Deno.env.get("EXAMPLE"), undefined); 388 | 389 | // This function simulates a new environment without the "PASSWORD" environment variable until the function returns. 390 | function test() { 391 | // Simulate a new production environment based on the current environment. 392 | using _environment = omitEnvironmentKeys(["PASSWORD"]); 393 | 394 | // Unspecified variables will still the same as before the simulation started. 395 | assertEquals(Deno.env.get("APP_ENV"), "test"); 396 | 397 | // The PASSWORD environment variable is now undefined. 398 | assertEquals(Deno.env.get("PASSWORD"), undefined); 399 | 400 | // Environment variables can be changed. 401 | Deno.env.set("APP_ENV", "production"); 402 | assertEquals(Deno.env.get("APP_ENV"), "production"); 403 | 404 | // New environment variables can be created. 405 | Deno.env.set("EXAMPLE", "example"); 406 | assertEquals(Deno.env.get("EXAMPLE"), "example"); 407 | } 408 | // Invoking the test function will simulate the environment and undo any changes made to it when the function returns. 409 | test(); 410 | 411 | // Environment variables are back to their original values. 412 | assertEquals(Deno.env.get("APP_ENV"), "test"); 413 | assertEquals(Deno.env.get("PASSWORD"), "hunter2"); 414 | assertEquals(Deno.env.get("EXAMPLE"), undefined); 415 | }); 416 | -------------------------------------------------------------------------------- /test-utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This module provides utilities for testing the application. 3 | * 4 | * @module 5 | */ 6 | import { _env, getEnvironment, isServer } from "./env.ts"; 7 | import type { AppData, AppWindow } from "./env.ts"; 8 | import { ErrorContext, InitialStateContext } from "./context.ts"; 9 | 10 | export { ErrorContext, InitialStateContext }; 11 | export type { AppData }; 12 | 13 | /** A simulated browser environment resource that is disposable. */ 14 | export interface SimulatedBrowser extends Disposable { 15 | /** Stops simulating the browser environment. */ 16 | end(): void; 17 | } 18 | 19 | /** 20 | * This function sets up a new simulated browser environment. When end is called, it will stop simulating the browser environment. 21 | * 22 | * When called without any arguments, it will simulate a new browser environment with the current environment's `APP_ENV` environment variable. 23 | * 24 | * ```ts 25 | * import { assertEquals } from "@std/assert"; 26 | * import { getEnvironment, isBrowser, isDevelopment, isProduction, isServer, isTest } from "@udibo/react-app"; 27 | * import { startBrowser } from "@udibo/react-app/test-utils"; 28 | * 29 | * // This code is running on the server. 30 | * assertEquals(isBrowser(), false); 31 | * assertEquals(isServer(), true); 32 | * assertEquals(getEnvironment(), "test"); 33 | * assertEquals(isDevelopment(), false); 34 | * assertEquals(isProduction(), false); 35 | * assertEquals(isTest(), true); 36 | * 37 | * // Simulate a new browser environment. 38 | * const browser = startBrowser(); 39 | * try { 40 | * // It now looks like the code is running in the browser. 41 | * assertEquals(isBrowser(), true); 42 | * assertEquals(isServer(), false); 43 | * assertEquals(getEnvironment(), "test"); 44 | * assertEquals(isDevelopment(), false); 45 | * assertEquals(isProduction(), false); 46 | * assertEquals(isTest(), true); 47 | * } finally { 48 | * // End the simulation. 49 | * browser.end(); 50 | * } 51 | * 52 | * // It no longer looks like the code is running in the browser. 53 | * assertEquals(isBrowser(), false); 54 | * assertEquals(isServer(), true); 55 | * assertEquals(getEnvironment(), "test"); 56 | * assertEquals(isDevelopment(), false); 57 | * assertEquals(isProduction(), false); 58 | * assertEquals(isTest(), true); 59 | * ``` 60 | * 61 | * When called with an `app` argument, it will simulate a new browser environment based on it. 62 | * 63 | * ```ts 64 | * import { assertEquals } from "@std/assert"; 65 | * import { getEnvironment, isBrowser, isDevelopment, isProduction, isServer, isTest } from "@udibo/react-app"; 66 | * import { startBrowser } from "@udibo/react-app/test-utils"; 67 | * 68 | * // This code is running on the server. 69 | * assertEquals(isBrowser(), false); 70 | * assertEquals(isServer(), true); 71 | * assertEquals(getEnvironment(), "test"); 72 | * assertEquals(isDevelopment(), false); 73 | * assertEquals(isProduction(), false); 74 | * assertEquals(isTest(), true); 75 | * 76 | * // Simulate a new browser environment in development mode. 77 | * const browser = startBrowser({ 78 | * env: "development", 79 | * initialState: {}, 80 | * }); 81 | * try { 82 | * // It now looks like the code is running in the browser. 83 | * assertEquals(isBrowser(), true); 84 | * assertEquals(isServer(), false); 85 | * assertEquals(getEnvironment(), "development"); 86 | * assertEquals(isDevelopment(), true); 87 | * assertEquals(isProduction(), false); 88 | * assertEquals(isTest(), false); 89 | * } finally { 90 | * // End the simulation. 91 | * browser.end(); 92 | * } 93 | * 94 | * // It no longer looks like the code is running in the browser. 95 | * assertEquals(isBrowser(), false); 96 | * assertEquals(isServer(), true); 97 | * assertEquals(getEnvironment(), "test"); 98 | * assertEquals(isDevelopment(), false); 99 | * assertEquals(isProduction(), false); 100 | * assertEquals(isTest(), true); 101 | * ``` 102 | * 103 | * The simulated browser environment is disposable meaning it will automatically call the end function when leaving a scope that it is used in. 104 | * 105 | * ```ts 106 | * import { assertEquals } from "@std/assert"; 107 | * import { getEnvironment, isBrowser, isDevelopment, isProduction, isServer, isTest } from "@udibo/react-app"; 108 | * import { startBrowser } from "@udibo/react-app/test-utils"; 109 | * 110 | * // This code is running on the server. 111 | * assertEquals(isBrowser(), false); 112 | * assertEquals(isServer(), true); 113 | * assertEquals(getEnvironment(), "test"); 114 | * assertEquals(isDevelopment(), false); 115 | * assertEquals(isProduction(), false); 116 | * assertEquals(isTest(), true); 117 | * 118 | * // This function simulates a new browser environment until the function returns. 119 | * function test() { 120 | * // Simulate a new browser environment in production mode. 121 | * using _browser = startBrowser({ 122 | * env: "production", 123 | * initialState: {}, 124 | * }); 125 | * 126 | * // It now looks like the code is running in the browser. 127 | * assertEquals(isBrowser(), true); 128 | * assertEquals(isServer(), false); 129 | * assertEquals(getEnvironment(), "production"); 130 | * assertEquals(isDevelopment(), false); 131 | * assertEquals(isProduction(), true); 132 | * assertEquals(isTest(), false); 133 | * } 134 | * // Invoking the test function will simulate the browser environment and undo any changes made to it when the function returns. 135 | * test(); 136 | * 137 | * // It no longer looks like the code is running in the browser. 138 | * assertEquals(isBrowser(), false); 139 | * assertEquals(isServer(), true); 140 | * assertEquals(getEnvironment(), "test"); 141 | * assertEquals(isDevelopment(), false); 142 | * assertEquals(isProduction(), false); 143 | * assertEquals(isTest(), true); 144 | * ``` 145 | * 146 | * Calling the end function allows you to stop simulating the browser without having to leave the scope where browser mode was started. 147 | * 148 | * @param app - A JSON representation of the app in the simulated browser environment. 149 | * @returns A simulated browser environment resource that is disposable. 150 | */ 151 | export function startBrowser< 152 | SharedState extends Record = Record, 153 | >(app?: AppData): SimulatedBrowser { 154 | const originalWindow = globalThis.window; 155 | globalThis.window = originalWindow ?? {}; 156 | const originalApp = (globalThis.window as AppWindow).app; 157 | if (!app) { 158 | app = { 159 | env: getEnvironment(), 160 | initialState: {} as SharedState, 161 | }; 162 | } 163 | (globalThis.window as AppWindow).app = app; 164 | 165 | const isServer = _env.isServer; 166 | _env.isServer = false; 167 | 168 | return { 169 | end(): void { 170 | _env.isServer = isServer; 171 | if (originalApp) { 172 | (globalThis.window as AppWindow).app = originalApp; 173 | } else { 174 | delete (globalThis.window as AppWindow).app; 175 | } 176 | globalThis.window = originalWindow; 177 | }, 178 | [Symbol.dispose]() { 179 | this.end(); 180 | }, 181 | }; 182 | } 183 | 184 | /** A simulated environment resource that is disposable. */ 185 | export interface SimulatedEnvironment extends Disposable { 186 | /** Stops simulating the environment. */ 187 | end(): void; 188 | } 189 | 190 | /** 191 | * This function sets up a new simulated environment based on the current environment. When end is called, it will stop simulating the environment. 192 | * 193 | * When called without any arguments, it will simulate the current environment and undo any changes made to it when end is called. 194 | * 195 | * ```ts 196 | * import { assertEquals } from "@std/assert"; 197 | * import { getEnvironment, isDevelopment, isProduction, isTest } from "@udibo/react-app"; 198 | * import { startEnvironment } from "@udibo/react-app/test-utils"; 199 | * 200 | * // Environment variables before simulating the environment. 201 | * assertEquals(getEnvironment(), "test"); 202 | * assertEquals(isDevelopment(), false); 203 | * assertEquals(isProduction(), false); 204 | * assertEquals(isTest(), true); 205 | * assertEquals(Deno.env.get("APP_ENV"), "test"); 206 | * assertEquals(Deno.env.get("PASSWORD"), "hunter2"); 207 | * assertEquals(Deno.env.get("EXAMPLE"), undefined); 208 | * 209 | * // Simulate a new environment based on the current environment. 210 | * const environment = startEnvironment(); 211 | * try { 212 | * // Unchanged variables are still the same as before the simulation started. 213 | * assertEquals(getEnvironment(), "test"); 214 | * assertEquals(isDevelopment(), false); 215 | * assertEquals(isProduction(), false); 216 | * assertEquals(isTest(), true); 217 | * assertEquals(Deno.env.get("APP_ENV"), "test"); 218 | * assertEquals(Deno.env.get("EXAMPLE"), undefined); 219 | * 220 | * // Environment variables can be changed. 221 | * Deno.env.set("APP_ENV", "production"); 222 | * assertEquals(getEnvironment(), "production"); 223 | * assertEquals(isDevelopment(), false); 224 | * assertEquals(isProduction(), true); 225 | * assertEquals(isTest(), false); 226 | * assertEquals(Deno.env.get("APP_ENV"), "production"); 227 | * Deno.env.set("PASSWORD", "qwerty"); 228 | * assertEquals(Deno.env.get("PASSWORD"), "qwerty"); 229 | * 230 | * // New environment variables can be created. 231 | * Deno.env.set("EXAMPLE", "example"); 232 | * assertEquals(Deno.env.get("EXAMPLE"), "example"); 233 | * } finally { 234 | * // End the simulation. 235 | * environment.end(); 236 | * } 237 | * 238 | * // Environment variables are back to their original values. 239 | * assertEquals(getEnvironment(), "test"); 240 | * assertEquals(isDevelopment(), false); 241 | * assertEquals(isProduction(), false); 242 | * assertEquals(isTest(), true); 243 | * assertEquals(Deno.env.get("APP_ENV"), "test"); 244 | * assertEquals(Deno.env.get("PASSWORD"), "hunter2"); 245 | * assertEquals(Deno.env.get("EXAMPLE"), undefined); 246 | * ``` 247 | * 248 | * When called with an `appEnvironment` argument, it will simulate the app environment and undo any changes made to it when end is called. 249 | * 250 | * ```ts 251 | * import { assertEquals } from "@std/assert"; 252 | * import { getEnvironment, isDevelopment, isProduction, isTest } from "@udibo/react-app"; 253 | * import { startEnvironment } from "@udibo/react-app/test-utils"; 254 | * 255 | * // Environment variables before simulating the environment 256 | * assertEquals(getEnvironment(), "test"); 257 | * assertEquals(isDevelopment(), false); 258 | * assertEquals(isProduction(), false); 259 | * assertEquals(isTest(), true); 260 | * assertEquals(Deno.env.get("APP_ENV"), "test"); 261 | * assertEquals(Deno.env.get("PASSWORD"), "hunter2"); 262 | * assertEquals(Deno.env.get("EXAMPLE"), undefined); 263 | * 264 | * // Simulate a new production environment based on the current environment. 265 | * const environment = startEnvironment("production"); 266 | * try { 267 | * // The APP_ENV environment variable is now "production". 268 | * assertEquals(getEnvironment(), "production"); 269 | * assertEquals(isDevelopment(), false); 270 | * assertEquals(isProduction(), true); 271 | * assertEquals(isTest(), false); 272 | * assertEquals(Deno.env.get("APP_ENV"), "production"); 273 | * 274 | * // unchanged variables are still the same as before the simulation started. 275 | * assertEquals(Deno.env.get("PASSWORD"), "hunter2"); 276 | * 277 | * // Environment variables can be changed. 278 | * Deno.env.set("PASSWORD", "qwerty"); 279 | * assertEquals(Deno.env.get("PASSWORD"), "qwerty"); 280 | * 281 | * // New environment variables can be created. 282 | * Deno.env.set("EXAMPLE", "example"); 283 | * assertEquals(Deno.env.get("EXAMPLE"), "example"); 284 | * } finally { 285 | * // End the simulation. 286 | * environment.end(); 287 | * } 288 | * 289 | * // Environment variables are back to their original values. 290 | * assertEquals(getEnvironment(), "test"); 291 | * assertEquals(isDevelopment(), false); 292 | * assertEquals(isProduction(), false); 293 | * assertEquals(isTest(), true); 294 | * assertEquals(Deno.env.get("APP_ENV"), "test"); 295 | * assertEquals(Deno.env.get("PASSWORD"), "hunter2"); 296 | * assertEquals(Deno.env.get("EXAMPLE"), undefined); 297 | * ``` 298 | * 299 | * When called with an `environment` argument, it will add that environment to the current environment and will undo changes made when exit is called. 300 | * 301 | * ```ts 302 | * import { assertEquals } from "@std/assert"; 303 | * import { getEnvironment, isDevelopment, isProduction, isTest } from "@udibo/react-app"; 304 | * import { startEnvironment } from "@udibo/react-app/test-utils"; 305 | * 306 | * // Environment variables before simulating the environment 307 | * assertEquals(getEnvironment(), "test"); 308 | * assertEquals(isDevelopment(), false); 309 | * assertEquals(isProduction(), false); 310 | * assertEquals(isTest(), true); 311 | * assertEquals(Deno.env.get("APP_ENV"), "test"); 312 | * assertEquals(Deno.env.get("PASSWORD"), "hunter2"); 313 | * assertEquals(Deno.env.get("EXAMPLE"), undefined); 314 | * 315 | * // Simulate a new production environment based on the current environment. 316 | * const environment = startEnvironment({ 317 | * APP_ENV: "production", 318 | * PASSWORD: "qwerty", 319 | * }); 320 | * try { 321 | * // The APP_ENV environment variable is now "production". 322 | * assertEquals(getEnvironment(), "production"); 323 | * assertEquals(isDevelopment(), false); 324 | * assertEquals(isProduction(), true); 325 | * assertEquals(isTest(), false); 326 | * assertEquals(Deno.env.get("APP_ENV"), "production"); 327 | * 328 | * // The PASSWORD environment variable is now "qwerty". 329 | * assertEquals(Deno.env.get("PASSWORD"), "qwerty"); 330 | * 331 | * // Environment variables can be changed. 332 | * Deno.env.set("PASSWORD", "123456"); 333 | * assertEquals(Deno.env.get("PASSWORD"), "123456"); 334 | * 335 | * // New environment variables can be created. 336 | * Deno.env.set("EXAMPLE", "example"); 337 | * assertEquals(Deno.env.get("EXAMPLE"), "example"); 338 | * } finally { 339 | * environment.end(); 340 | * } 341 | * 342 | * // Environment variables are back to their original values. 343 | * assertEquals(getEnvironment(), "test"); 344 | * assertEquals(isDevelopment(), false); 345 | * assertEquals(isProduction(), false); 346 | * assertEquals(isTest(), true); 347 | * assertEquals(Deno.env.get("APP_ENV"), "test"); 348 | * assertEquals(Deno.env.get("PASSWORD"), "hunter2"); 349 | * assertEquals(Deno.env.get("EXAMPLE"), undefined); 350 | * ``` 351 | * 352 | * When called with an `appEnvironment` and `environment` argument, it will simulate the app environment and add that environment to the current environment 353 | * and will undo changes made when exit is called. 354 | * 355 | * ```ts 356 | * import { assertEquals } from "@std/assert"; 357 | * import { getEnvironment, isDevelopment, isProduction, isTest } from "@udibo/react-app"; 358 | * import { startEnvironment } from "@udibo/react-app/test-utils"; 359 | * 360 | * // Environment variables before simulating the environment 361 | * assertEquals(getEnvironment(), "test"); 362 | * assertEquals(isDevelopment(), false); 363 | * assertEquals(isProduction(), false); 364 | * assertEquals(isTest(), true); 365 | * assertEquals(Deno.env.get("APP_ENV"), "test"); 366 | * assertEquals(Deno.env.get("PASSWORD"), "hunter2"); 367 | * assertEquals(Deno.env.get("EXAMPLE"), undefined); 368 | * 369 | * // Simulate a new production environment based on the current environment. 370 | * const environment = startEnvironment("production", { PASSWORD: "qwerty" }); 371 | * try { 372 | * // The APP_ENV environment variable is now "production". 373 | * assertEquals(getEnvironment(), "production"); 374 | * assertEquals(isDevelopment(), false); 375 | * assertEquals(isProduction(), true); 376 | * assertEquals(isTest(), false); 377 | * assertEquals(Deno.env.get("APP_ENV"), "production"); 378 | * 379 | * // The PASSWORD environment variable is now "qwerty". 380 | * assertEquals(Deno.env.get("PASSWORD"), "qwerty"); 381 | * 382 | * // Environment variables can be changed. 383 | * Deno.env.set("PASSWORD", "123456"); 384 | * assertEquals(Deno.env.get("PASSWORD"), "123456"); 385 | * 386 | * // New environment variables can be created. 387 | * Deno.env.set("EXAMPLE", "example"); 388 | * assertEquals(Deno.env.get("EXAMPLE"), "example"); 389 | * } finally { 390 | * environment.end(); 391 | * } 392 | * 393 | * // Environment variables are back to their original values. 394 | * assertEquals(getEnvironment(), "test"); 395 | * assertEquals(isDevelopment(), false); 396 | * assertEquals(isProduction(), false); 397 | * assertEquals(isTest(), true); 398 | * assertEquals(Deno.env.get("APP_ENV"), "test"); 399 | * assertEquals(Deno.env.get("PASSWORD"), "hunter2"); 400 | * assertEquals(Deno.env.get("EXAMPLE"), undefined); 401 | * ``` 402 | * 403 | * The simulated environment is disposable meaning it will automatically call the end function when leaving a scope that it is used in. 404 | * 405 | * ```ts 406 | * import { assertEquals } from "@std/assert"; 407 | * import { getEnvironment, isDevelopment, isProduction, isTest } from "@udibo/react-app"; 408 | * import { startEnvironment } from "@udibo/react-app/test-utils"; 409 | * 410 | * // Environment variables before simulating the environment 411 | * assertEquals(getEnvironment(), "test"); 412 | * assertEquals(isDevelopment(), false); 413 | * assertEquals(isProduction(), false); 414 | * assertEquals(isTest(), true); 415 | * assertEquals(Deno.env.get("APP_ENV"), "test"); 416 | * assertEquals(Deno.env.get("PASSWORD"), "hunter2"); 417 | * assertEquals(Deno.env.get("EXAMPLE"), undefined); 418 | * 419 | * // This function simulates a new production environment until the function returns. 420 | * function test() { 421 | * // Simulate a new production environment based on the current environment. 422 | * using _environment = startEnvironment("production", { PASSWORD: "qwerty" }); 423 | * 424 | * // The APP_ENV environment variable is now "production". 425 | * assertEquals(getEnvironment(), "production"); 426 | * assertEquals(isDevelopment(), false); 427 | * assertEquals(isProduction(), true); 428 | * assertEquals(isTest(), false); 429 | * assertEquals(Deno.env.get("APP_ENV"), "production"); 430 | * 431 | * // The PASSWORD environment variable is now "qwerty". 432 | * assertEquals(Deno.env.get("PASSWORD"), "qwerty"); 433 | * 434 | * // Environment variables can be changed. 435 | * Deno.env.set("PASSWORD", "123456"); 436 | * assertEquals(Deno.env.get("PASSWORD"), "123456"); 437 | * 438 | * // New environment variables can be created. 439 | * Deno.env.set("EXAMPLE", "example"); 440 | * assertEquals(Deno.env.get("EXAMPLE"), "example"); 441 | * } 442 | * // Invoking the test function will simulate the environment and undo any changes made to it when the function returns. 443 | * test(); 444 | * 445 | * // Environment variables are back to their original values. 446 | * assertEquals(getEnvironment(), "test"); 447 | * assertEquals(isDevelopment(), false); 448 | * assertEquals(isProduction(), false); 449 | * assertEquals(isTest(), true); 450 | * assertEquals(Deno.env.get("APP_ENV"), "test"); 451 | * assertEquals(Deno.env.get("PASSWORD"), "hunter2"); 452 | * assertEquals(Deno.env.get("EXAMPLE"), undefined); 453 | * ``` 454 | * 455 | * Calling the end function allows you to stop simulating the environment without having to leave the scope where it was started. 456 | * 457 | * @param appEnvironment - The name of the app environment to simulate. 458 | * @param environment - The environment to simulate. 459 | * @returns A simulated environment resource that is disposable. 460 | */ 461 | export function startEnvironment( 462 | environment?: Record, 463 | ): SimulatedEnvironment; 464 | export function startEnvironment( 465 | appEnvironment: string, 466 | environment?: Record, 467 | ): SimulatedEnvironment; 468 | export function startEnvironment( 469 | appEnvironment?: string | Record, 470 | environment?: Record, 471 | ): SimulatedEnvironment { 472 | if (!environment && typeof appEnvironment !== "string") { 473 | environment = appEnvironment; 474 | appEnvironment = undefined; 475 | } 476 | if (typeof environment?.APP_ENV === "string" && appEnvironment) { 477 | throw new Error( 478 | "Cannot specify APP_ENV in the environment when called with appEnvironment", 479 | ); 480 | } 481 | 482 | if (!("Deno" in globalThis)) { 483 | throw new Error("Can only simulate environment on a Deno server"); 484 | } 485 | 486 | const originalEnvironment = Deno.env.toObject(); 487 | 488 | if (isServer()) { 489 | if (environment) { 490 | for (const [key, value] of Object.entries(environment)) { 491 | if (typeof value === "string") { 492 | Deno.env.set(key, value); 493 | } else { 494 | Deno.env.delete(key); 495 | } 496 | } 497 | } 498 | if (typeof appEnvironment === "string") { 499 | Deno.env.set("APP_ENV", appEnvironment); 500 | } 501 | } 502 | 503 | return { 504 | end(): void { 505 | const currentEnvironment = Deno.env.toObject(); 506 | if (originalEnvironment && currentEnvironment) { 507 | for (const [key, value] of Object.entries(originalEnvironment)) { 508 | Deno.env.set(key, value); 509 | } 510 | 511 | for (const key of Object.keys(currentEnvironment)) { 512 | const value = originalEnvironment[key]; 513 | if (typeof value !== "string") { 514 | Deno.env.delete(key); 515 | } 516 | } 517 | } 518 | }, 519 | [Symbol.dispose]() { 520 | this.end(); 521 | }, 522 | }; 523 | } 524 | 525 | /** 526 | * This function sets up a new simulated environment based on the current environment without the specified keys. 527 | * When end is called, it will stop simulating the environment. 528 | * 529 | * ```ts 530 | * import { assertEquals } from "@std/assert"; 531 | * import { omitEnvironment } from "@udibo/react-app/test-utils"; 532 | * 533 | * // Environment variables before simulating the environment. 534 | * assertEquals(Deno.env.get("APP_ENV"), "test"); 535 | * assertEquals(Deno.env.get("PASSWORD"), "hunter2"); 536 | * assertEquals(Deno.env.get("EXAMPLE"), undefined); 537 | * 538 | * // Simulate a new environment based on the current environment without the "PASSWORD" environment variable. 539 | * const environment = omitEnvironmentKeys(["PASSWORD"]); 540 | * try { 541 | * // Unspecified variables will still the same as before the simulation started. 542 | * assertEquals(Deno.env.get("APP_ENV"), "test"); 543 | * 544 | * // The PASSWORD environment variable is now undefined. 545 | * assertEquals(Deno.env.get("PASSWORD"), undefined); 546 | * 547 | * // Environment variables can be changed. 548 | * Deno.env.set("APP_ENV", "production"); 549 | * assertEquals(Deno.env.get("APP_ENV"), "production"); 550 | * 551 | * // New environment variables can be created. 552 | * Deno.env.set("EXAMPLE", "example"); 553 | * assertEquals(Deno.env.get("EXAMPLE"), "example"); 554 | * } finally { 555 | * // End the simulation. 556 | * environment.end(); 557 | * } 558 | * 559 | * // Environment variables are back to their original values. 560 | * assertEquals(Deno.env.get("APP_ENV"), "test"); 561 | * assertEquals(Deno.env.get("PASSWORD"), "hunter2"); 562 | * assertEquals(Deno.env.get("EXAMPLE"), undefined); 563 | * ``` 564 | * 565 | * The simulated environment is disposable meaning it will automatically call the end function when leaving a scope that it is used in. 566 | * 567 | * ```ts 568 | * import { assertEquals } from "@std/assert"; 569 | * import { omitEnvironment } from "@udibo/react-app/test-utils"; 570 | * 571 | * // Environment variables before simulating the environment. 572 | * assertEquals(Deno.env.get("APP_ENV"), "test"); 573 | * assertEquals(Deno.env.get("PASSWORD"), "hunter2"); 574 | * assertEquals(Deno.env.get("EXAMPLE"), undefined); 575 | * 576 | * // This function simulates a new environment without the "PASSWORD" environment variable until the function returns. 577 | * function test() { 578 | * // Simulate a new production environment based on the current environment. 579 | * using _environment = omitEnvironmentKeys(["PASSWORD"]); 580 | * 581 | * // Unspecified variables will still the same as before the simulation started. 582 | * assertEquals(Deno.env.get("APP_ENV"), "test"); 583 | * 584 | * // The PASSWORD environment variable is now undefined. 585 | * assertEquals(Deno.env.get("PASSWORD"), undefined); 586 | * 587 | * // Environment variables can be changed. 588 | * Deno.env.set("APP_ENV", "production"); 589 | * assertEquals(Deno.env.get("APP_ENV"), "production"); 590 | * 591 | * // New environment variables can be created. 592 | * Deno.env.set("EXAMPLE", "example"); 593 | * assertEquals(Deno.env.get("EXAMPLE"), "example"); 594 | * } 595 | * // Invoking the test function will simulate the environment and undo any changes made to it when the function returns. 596 | * test(); 597 | * 598 | * // Environment variables are back to their original values. 599 | * assertEquals(Deno.env.get("APP_ENV"), "test"); 600 | * assertEquals(Deno.env.get("PASSWORD"), "hunter2"); 601 | * assertEquals(Deno.env.get("EXAMPLE"), undefined); 602 | * ``` 603 | * 604 | * Calling the end function allows you to stop simulating the environment without having to leave the scope where it was started. 605 | * 606 | * @param keys - The environment variables to omit from the simulated environment. 607 | * @returns A simulated environment resource that is disposable. 608 | */ 609 | export function omitEnvironmentKeys(keys: string[]): SimulatedEnvironment { 610 | if (keys.length === 0) { 611 | throw new Error("No keys to omit"); 612 | } 613 | const omitEnvironment = {} as Record; 614 | for (const key of keys) { 615 | omitEnvironment[key] = null; 616 | } 617 | return startEnvironment(omitEnvironment); 618 | } 619 | --------------------------------------------------------------------------------