From cf75f450ad35e38b4af2ba23a1315ff5b9d75bc8 Mon Sep 17 00:00:00 2001 From: Kyle June Date: Mon, 2 Sep 2024 19:00:56 -0400 Subject: [PATCH] Support Deno 2, add react types, and add more documentation (#103) * Remove global window dependency for Deno 2 * Add react.d.ts and error handling documentation * Move jsx pragmas after module doc for jsr to recognize it * Start work on configuration guide * Update lock file * Regenerate deno.lock without DENO_FUTURE flag --- client.tsx | 6 +- deno.jsonc | 23 ++- deno.lock | 69 +++++---- docs/configuration.md | 115 ++++++++++++-- docs/error-handling.md | 322 +++++++++++++++++++++++++++++++++++++++- docs/getting-started.md | 45 ++++-- docs/routing.md | 6 +- env.ts | 2 +- error.tsx | 6 +- react.d.ts | 5 + server.tsx | 6 +- test-utils.test.ts | 12 +- test-utils.ts | 11 +- 13 files changed, 531 insertions(+), 97 deletions(-) create mode 100644 react.d.ts diff --git a/client.tsx b/client.tsx index 00e61b0..882bf38 100644 --- a/client.tsx +++ b/client.tsx @@ -1,12 +1,12 @@ -/** @jsxRuntime automatic */ -/** @jsxImportSource npm:react@18 */ -/** @jsxImportSourceTypes npm:@types/react@18 */ /** * This module is meant for internal use only. It contains functions used to render the application. * It is only expected to be imported from the `_main.tsx` file in the routes directory that is generated by the build script. * * @module */ +/** @jsxRuntime automatic */ +/** @jsxImportSource npm:react@18 */ +/** @jsxImportSourceTypes npm:@types/react@18 */ import { lazy as reactLazy, startTransition, StrictMode } from "react"; import type { ComponentType, LazyExoticComponent } from "react"; import * as reactHelmetAsync from "react-helmet-async"; diff --git a/deno.jsonc b/deno.jsonc index 59d168f..c384c24 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -1,6 +1,6 @@ { "name": "@udibo/react-app", - "version": "0.24.2", + "version": "0.24.3", "exports": { ".": "./mod.tsx", "./build": "./build.ts", @@ -24,7 +24,8 @@ "**/*.test.tsx", "test-utils.tsx", "example", - "coverage" + "coverage", + "node_modules" ] }, "tasks": { @@ -58,17 +59,13 @@ "jsxImportSourceTypes": "@types/react" }, "nodeModulesDir": true, - "lint": { - "exclude": [ - "coverage", - "example/public/build", - "example/routes/_main.ts", - "example/routes/_main.tsx" - ] - }, - "fmt": { - "exclude": ["coverage", "example/public/build"] - }, + "exclude": [ + "coverage", + "node_modules", + "example/public/build", + "example/routes/_main.ts", + "example/routes/_main.tsx" + ], "imports": { "@udibo/http-error": "jsr:@udibo/http-error@0", "@udibo/react-app": "./mod.tsx", diff --git a/deno.lock b/deno.lock index 11c5a42..4e99be5 100644 --- a/deno.lock +++ b/deno.lock @@ -7,11 +7,11 @@ "jsr:@oak/oak@16": "jsr:@oak/oak@16.1.0", "jsr:@std/assert@0.223": "jsr:@std/assert@0.223.0", "jsr:@std/assert@0.226": "jsr:@std/assert@0.226.0", - "jsr:@std/assert@1": "jsr:@std/assert@1.0.2", + "jsr:@std/assert@1": "jsr:@std/assert@1.0.3", "jsr:@std/assert@^0.213.1": "jsr:@std/assert@0.213.1", "jsr:@std/assert@^0.223.0": "jsr:@std/assert@0.223.0", "jsr:@std/assert@^0.224.0": "jsr:@std/assert@0.224.0", - "jsr:@std/async@1": "jsr:@std/async@1.0.2", + "jsr:@std/async@1": "jsr:@std/async@1.0.4", "jsr:@std/bytes@0.223": "jsr:@std/bytes@0.223.0", "jsr:@std/bytes@0.224": "jsr:@std/bytes@0.224.0", "jsr:@std/bytes@^0.223.0": "jsr:@std/bytes@0.223.0", @@ -20,25 +20,25 @@ "jsr:@std/encoding@0.213": "jsr:@std/encoding@0.213.1", "jsr:@std/encoding@1.0.0-rc.2": "jsr:@std/encoding@1.0.0-rc.2", "jsr:@std/encoding@^0.223.0": "jsr:@std/encoding@0.223.0", - "jsr:@std/fmt@^1.0.0-rc.1": "jsr:@std/fmt@1.0.0", - "jsr:@std/fs@1": "jsr:@std/fs@1.0.1", - "jsr:@std/fs@^1.0.0-rc.5": "jsr:@std/fs@1.0.1", + "jsr:@std/fmt@^1.0.1": "jsr:@std/fmt@1.0.1", + "jsr:@std/fs@1": "jsr:@std/fs@1.0.2", + "jsr:@std/fs@^1.0.2": "jsr:@std/fs@1.0.2", "jsr:@std/http@0": "jsr:@std/http@0.224.5", "jsr:@std/http@0.223": "jsr:@std/http@0.223.0", "jsr:@std/http@0.224": "jsr:@std/http@0.224.5", - "jsr:@std/internal@^1.0.1": "jsr:@std/internal@1.0.1", + "jsr:@std/internal@^1.0.2": "jsr:@std/internal@1.0.2", "jsr:@std/io@0.223": "jsr:@std/io@0.223.0", - "jsr:@std/io@^0.224.3": "jsr:@std/io@0.224.4", + "jsr:@std/io@^0.224.6": "jsr:@std/io@0.224.6", "jsr:@std/json@^0.213.1": "jsr:@std/json@0.213.1", "jsr:@std/jsonc@0.213": "jsr:@std/jsonc@0.213.1", - "jsr:@std/log@0": "jsr:@std/log@0.224.5", + "jsr:@std/log@0": "jsr:@std/log@0.224.6", "jsr:@std/media-types@0.223": "jsr:@std/media-types@0.223.0", "jsr:@std/media-types@0.224": "jsr:@std/media-types@0.224.1", "jsr:@std/path@0.213": "jsr:@std/path@0.213.1", "jsr:@std/path@0.223": "jsr:@std/path@0.223.0", - "jsr:@std/path@1": "jsr:@std/path@1.0.2", - "jsr:@std/path@^1.0.2": "jsr:@std/path@1.0.2", - "jsr:@std/testing@1": "jsr:@std/testing@1.0.0", + "jsr:@std/path@1": "jsr:@std/path@1.0.3", + "jsr:@std/path@^1.0.3": "jsr:@std/path@1.0.3", + "jsr:@std/testing@1": "jsr:@std/testing@1.0.1", "jsr:@udibo/http-error@0": "jsr:@udibo/http-error@0.8.2", "npm:@testing-library/react@16": "npm:@testing-library/react@16.0.0_@testing-library+dom@10.4.0_@types+react@18.3.3_react@18.3.1_react-dom@18.3.1__react@18.3.1", "npm:@types/node": "npm:@types/node@18.16.19", @@ -48,7 +48,6 @@ "npm:path-to-regexp@6.2.1": "npm:path-to-regexp@6.2.1", "npm:react-dom@18": "npm:react-dom@18.3.1_react@18.3.1", "npm:react-error-boundary@4": "npm:react-error-boundary@4.0.13_react@18.3.1", - "npm:react-error-boundary@4.0.13": "npm:react-error-boundary@4.0.13_react@18.3.1", "npm:react-helmet-async@2": "npm:react-helmet-async@2.0.5_react@18.3.1", "npm:react-router-dom@6": "npm:react-router-dom@6.26.0_react@18.3.1_react-dom@18.3.1__react@18.3.1", "npm:react-router-dom@6.26.0": "npm:react-router-dom@6.26.0_react@18.3.1_react-dom@18.3.1__react@18.3.1", @@ -101,14 +100,14 @@ "@std/assert@0.226.0": { "integrity": "0dfb5f7c7723c18cec118e080fec76ce15b4c31154b15ad2bd74822603ef75b3" }, - "@std/assert@1.0.2": { - "integrity": "ccacec332958126deaceb5c63ff8b4eaf9f5ed0eac9feccf124110435e59e49c", + "@std/assert@1.0.3": { + "integrity": "b0d03ce1ced880df67132eea140623010d415848df66f6aa5df76507ca7c26d8", "dependencies": [ - "jsr:@std/internal@^1.0.1" + "jsr:@std/internal@^1.0.2" ] }, - "@std/async@1.0.2": { - "integrity": "36e7f0f922c843b45df546857d269f01ed4d0406aced2a6639eac325b2435e43" + "@std/async@1.0.4": { + "integrity": "373f5168a01b46ecaabc785d4e0f9ef18a010ab867af069fb905d93a9129ae5b" }, "@std/bytes@0.223.0": { "integrity": "84b75052cd8680942c397c2631318772b295019098f40aac5c36cead4cba51a8" @@ -138,13 +137,13 @@ "@std/encoding@1.0.0-rc.2": { "integrity": "160d7674a20ebfbccdf610b3801fee91cf6e42d1c106dd46bbaf46e395cd35ef" }, - "@std/fmt@1.0.0": { - "integrity": "8a95c9fdbb61559418ccbc0f536080cf43341655e1444f9d375a66886ceaaa3d" + "@std/fmt@1.0.1": { + "integrity": "ef76c37faa7720faa8c20fd8cc74583f9b1e356dfd630c8714baa716a45856ab" }, - "@std/fs@1.0.1": { - "integrity": "d6914ca2c21abe591f733b31dbe6331e446815e513e2451b3b9e472daddfefcb", + "@std/fs@1.0.2": { + "integrity": "af57555c7a224a6f147d5cced5404692974f7a628ced8eda67e0d22d92d474ec", "dependencies": [ - "jsr:@std/path@^1.0.2" + "jsr:@std/path@^1.0.3" ] }, "@std/http@0.223.0": { @@ -160,8 +159,8 @@ "jsr:@std/encoding@1.0.0-rc.2" ] }, - "@std/internal@1.0.1": { - "integrity": "6f8c7544d06a11dd256c8d6ba54b11ed870aac6c5aeafff499892662c57673e6" + "@std/internal@1.0.2": { + "integrity": "f4cabe2021352e8bfc24e6569700df87bf070914fc38d4b23eddd20108ac4495" }, "@std/io@0.223.0": { "integrity": "2d8c3c2ab3a515619b90da2c6ff5ea7b75a94383259ef4d02116b228393f84f1", @@ -169,8 +168,8 @@ "jsr:@std/bytes@^0.223.0" ] }, - "@std/io@0.224.4": { - "integrity": "bce1151765e4e70e376039fd72c71672b4d4aae363878a5ee3e58361b81197ec" + "@std/io@0.224.6": { + "integrity": "eefe034a370be34daf066c8634dd645635d099bb21eccf110f0bdc28d9040891" }, "@std/json@0.213.1": { "integrity": "f572b1de605d07c4a5602445dac54bfc51b1fb87a3710a17aed2608bfca54e68" @@ -182,12 +181,12 @@ "jsr:@std/json@^0.213.1" ] }, - "@std/log@0.224.5": { - "integrity": "4612a45189438441bbd923a4cad1cce5c44c6c4a039195a3e8d831ce38894eee", + "@std/log@0.224.6": { + "integrity": "c34e7b69fe84b2152ce2b0a1b5e211b26e6a1ce6a6b68a217b9342fd13ed95a4", "dependencies": [ - "jsr:@std/fmt@^1.0.0-rc.1", - "jsr:@std/fs@^1.0.0-rc.5", - "jsr:@std/io@^0.224.3" + "jsr:@std/fmt@^1.0.1", + "jsr:@std/fs@^1.0.2", + "jsr:@std/io@^0.224.6" ] }, "@std/media-types@0.223.0": { @@ -208,11 +207,11 @@ "jsr:@std/assert@^0.223.0" ] }, - "@std/path@1.0.2": { - "integrity": "a452174603f8c620bd278a380c596437a9eef50c891c64b85812f735245d9ec7" + "@std/path@1.0.3": { + "integrity": "cd89d014ce7eb3742f2147b990f6753ee51d95276bfc211bc50c860c1bc7df6f" }, - "@std/testing@1.0.0": { - "integrity": "27cfc06392c69c2acffe54e6d0bcb5f961cf193f519255372bd4fff1481bfef8" + "@std/testing@1.0.1": { + "integrity": "9c25841137ee818933e1722091bb9ed5fdc251c35e84c97979a52196bdb6c5c3" }, "@udibo/http-error@0.8.2": { "integrity": "db400c3d169f308b64b7349875b164562f947eb08a9079d3a1da41f53e034907", diff --git a/docs/configuration.md b/docs/configuration.md index 92645d7..4cdec9c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,14 +1,13 @@ # Configuration -TODO: Make an outline then fill it in with details. +This guide covers the configuration options available for your project. - [Configuration](#configuration) - [Tasks](#tasks) - [Compiler options](#compiler-options) - - [Linting](#linting) - - [Formatting](#formatting) + - [Formatting and linting](#formatting-and-linting) - [Imports](#imports) - - [Application](#application) + - [Server](#server) - [Build](#build) - [esbuild](#esbuild) - [Development](#development) @@ -17,7 +16,8 @@ TODO: Make an outline then fill it in with details. ## Tasks To learn more about using the default tasks, see the -[tasks](getting-started.md#tasks) section in the getting started guide. +[tasks](getting-started.md#tasks) section in the getting started guide. Each +task has a description of what it does in a comment above the task declaration. If you need to customize your build options to be different from the default, follow the instructions for adding the [build.ts](getting-started.md#buildts) @@ -35,13 +35,107 @@ workflows, you may need to modify them to use different tasks. See the ## Compiler options -## Linting - -## Formatting +The default compiler options from the +[getting started guide](getting-started.md#denojsonc) should be sufficient for +most use cases. If you need to customize them, you can do so by modifying the +`compilerOptions` in the `deno.jsonc` file. + +```json +{ + "compilerOptions": { + "lib": ["esnext", "dom", "dom.iterable", "dom.asynciterable", "deno.ns"], + "jsx": "react-jsx", + "jsxImportSource": "react", + "jsxImportSourceTypes": "@types/react" + } +} +``` + +For more information about the available options, see Deno's +[configuring TypeScript in Deno guide](https://docs.deno.com/runtime/manual/advanced/typescript/configuration/). + +## Formatting and linting + +You can configure Deno's formatter and linter to include or ignore files or +directories by adding the fmt or lint key to your configuration. Alternatively, +if you only want to exclude the same files or directories for both, you can +update the top level excludes array. Below is the default excludes array from +the [getting started guide](getting-started.md#denojsonc). It ensures that the +formatter ignores coverage report json files, your npm dependencies stored in +the node_modules directory, and the build artifacts from this framework. + +```json +{ + "exclude": [ + "coverage", + "node_modules", + "public/build", + "routes/_main.ts", + "routes/_main.tsx" + ] +} +``` ## Imports -## Application +The imports section of the `deno.jsonc` file is used to configure an import map +for resolving bare specifiers. It makes it so that you don't have to specify the +version everywhere that your dependency is used and provides one centralized +place for updating those versions. + +For example, if your import map has the entry `"react": "npm:react@18.3.1"`, +you'll be able to import react like `import React from "react"` and it will +resolve to `npm:react@18.3.1`. + +The default import map from the +[getting started guide](getting-started.md#denojsonc) also has 2 entries in it +that make it easy to import files relative to the root of your project. Instead +of having to import files with a path relative to the current file, you can +import them with a path relative to the root of your project. For example, if +you have your shared components in the components directory, you can import them +like `import Button from "/components/Button.tsx"` instead of +`import Button from "../../components/Button.tsx"`. + +```json +{ + "imports": { + "/": "./", + "./": "./" + } +} +``` + +For more information about import maps, see Deno's +[import map](https://docs.deno.com/runtime/manual/basics/import_maps/) +documentation. + +## Server + +In all of the examples, the main entry point for the application is the +`main.ts` or `main.tsx` file. This is the file that is used to start the +application and contains the configuration for starting it. For most +applications, you can just use the serve function to start your application. If +a port is not specified, the operating system will choose an available port +automatically. The route and router options come from geenerated build +artifacts. The working directory is the directory that contains the main entry +point file. The configuration for logging is stored in the `log.ts` file, for +more information about configuring logging, see the [logging](logging.md) guide. + +```ts +import * as path from "@std/path"; +import { serve } from "@udibo/react-app/server"; + +import route from "./routes/_main.tsx"; +import router from "./routes/_main.ts"; +import "./log.ts"; + +await serve({ + port: 9000, + router, + route, + workingDirectory: path.dirname(path.fromFileUrl(import.meta.url)), +}); +``` ## Build @@ -55,4 +149,5 @@ using common styling plugins for esbuild. ## Environment variables TODO: Cover the basics of environment variables, with a focus on how to use -dotfiles for development, production, and test environment variables. +dotfiles for development, production, and test environment variables. Update +tasks to use dotfiles. diff --git a/docs/error-handling.md b/docs/error-handling.md index d560616..22450ee 100644 --- a/docs/error-handling.md +++ b/docs/error-handling.md @@ -2,14 +2,230 @@ - [Error handling](#error-handling) - [UI](#ui) + - [Adding error boundaries](#adding-error-boundaries) + - [Exporting an ErrorFallback component](#exporting-an-errorfallback-component) + - [Manually adding an error boundary](#manually-adding-an-error-boundary) + - [SSR](#ssr) - [API](#api) + - [Default Error Handling](#default-error-handling) + - [Creating and Throwing HttpErrors](#creating-and-throwing-httperrors) + - [Controlling Error Exposure](#controlling-error-exposure) + - [Adding Additional Error Data](#adding-additional-error-data) + - [Overriding Default Error Handling](#overriding-default-error-handling) ## UI -For UI routes, error handling typically involves using the included error -handling components and hooks. This allows you to catch and display errors in a -user-friendly manner, preventing the entire application from crashing due to a -single component error. +For UI routes, error handling typically involves using error boundaries to catch +and display errors in a user-friendly manner, preventing the entire application +from crashing due to a single component error. + +### Adding error boundaries + +There are two main ways to add error handling to UI routes, either by exporting +an `ErrorFallback` component from the route file or by manually adding an error +boundary to your component. + +#### Exporting an ErrorFallback component + +The simplest way to add error handling to a UI route is to export an +`ErrorFallback` component from the route file. The framework will automatically +wrap the route's default export with an error boundary using this fallback. + +Example: + +```tsx +import { FallbackProps, HttpError } from "@udibo/react-app"; + +export default function BlogPost() { + // ... component logic +} + +export function ErrorFallback({ error }: FallbackProps) { + return ( +
+

Error

+

{error.message}

+
+ ); +} + +// Optionally, you can specify a custom boundary name +export const boundary = "BlogPostErrorBoundary"; +``` + +In this example, if an error occurs within the `BlogPost` component, the +`ErrorFallback` component will be rendered instead. + +#### Manually adding an error boundary + +For more control over error handling, you can manually add an error boundary to +your component using the `ErrorBoundary` component or the `withErrorBoundary` +higher-order component. + +Using `ErrorBoundary`: + +```tsx +import { DefaultErrorFallback, ErrorBoundary } from "@udibo/react-app"; + +export default function Blog() { + return ( + + {/* Blog content */} + + ); +} +``` + +Using `withErrorBoundary`: + +```tsx +import { DefaultErrorFallback, withErrorBoundary } from "@udibo/react-app"; + +function Blog() { + // ... component logic +} + +export default withErrorBoundary(Blog, { + FallbackComponent: DefaultErrorFallback, + boundary: "BlogErrorBoundary", +}); +``` + +### SSR + +In the browser, any errors that occur within a route will be caught by the +nearest error boundary. When rendering on the server, the errors will have a +boundary key added to them to indicate which error boundary they should be +associated with during rendering. If a route throws an error, it will default to +the nearest route's error boundary. + +In the following example, any errors thrown in the route will automatically have +the boundary key set to the boundary for that route. If the route path is +`/blog/:id` and the UI route file doesn't export a boundary constant, any errors +thrown will have the `/blog/:id` boundary added to them. If the UI route file +does export a boundary constant, that will be used instead of the default. + +```ts +import { HttpError } from "@udibo/react-app"; +import { Router } from "@udibo/react-app/server"; + +import { getPost } from "../../services/posts.ts"; +import type { PostsState } from "../../models/posts.ts"; + +export default new Router() + .get("/", async (context) => { + const { state, params } = context; + const id = Number(params.id); + if (isNaN(id) || Math.floor(id) !== id || id < 0) { + throw new HttpError(400, "Invalid id"); + } + + state.app.initialState.posts = { + [id]: getPost(id), + }; + await state.app.render(); + }); +``` + +Routes can have as many error boundaries as you need. If you want an error on +the server to be caught by a specific boundary when doing server-side rendering, +you'll need the error to be thrown with the boundary key set to the name of the +boundary you want to catch the error. This can be done automatically by using +the `errorBoundary` middleware. The following example shows how to use the +`errorBoundary` middleware to catch errors in a route. Now instead of the errors +having the routes boundary key added to them, it will have the +`BlogErrorBoundary` boundary key added to them instead. + +```ts +import { HttpError } from "@udibo/react-app"; +import { Router } from "@udibo/react-app/server"; + +import { getPost } from "../../services/posts.ts"; +import type { PostsState } from "../../models/posts.ts"; + +export default new Router() + .use(errorBoundary("BlogErrorBoundary")) + .get("/", async (context) => { + const { state, params } = context; + const id = Number(params.id); + if (isNaN(id) || Math.floor(id) !== id || id < 0) { + throw new HttpError(400, "Invalid id"); + } + + state.app.initialState.posts = { + [id]: getPost(id), + }; + await state.app.render(); + }); +``` + +Alternatively, you can throw an `HttpError` with the boundary key set to the +name of the boundary you want to catch the error. In the following example, the +invalid id error will be caught by the `BlogErrorBoundary` boundary instead of +the default boundary. Any other errors will still be caught by the route's +boundary. + +```ts +import { HttpError } from "@udibo/react-app"; +import { Router } from "@udibo/react-app/server"; + +import { getPost } from "../../services/posts.ts"; +import type { PostsState } from "../../models/posts.ts"; + +export default new Router() + .get("/", async (context) => { + const { state, params } = context; + const id = Number(params.id); + if (isNaN(id) || Math.floor(id) !== id || id < 0) { + throw new HttpError(400, "Invalid id", { boundary: "BlogErrorBoundary" }); + } + + state.app.initialState.posts = { + [id]: getPost(id), + }; + await state.app.render(); + }); +``` + +If the error could come from somewhere else that doesn't throw an HttpError with +a boundary, you can catch and re-throw it as an HttpError with the correct +boundary like shown in the following example. It is doing the same thing as the +error boundary middleware, but only applying to the code within the try +statement. Any errors thrown within there will have their boundary set to +`BlogErrorBoundary`. Any errors thrown outside of there will have their boundary +set to the route's boundary. + +```ts +import { HttpError } from "@udibo/react-app"; +import { Router } from "@udibo/react-app/server"; + +import { getPost } from "../../services/posts.ts"; +import type { PostsState } from "../../models/posts.ts"; + +export default new Router() + .get("/", async (context) => { + const { state, params } = context; + const id = Number(params.id); + if (isNaN(id) || Math.floor(id) !== id || id < 0) { + throw new HttpError(400, "Invalid id"); + } + + try { + state.app.initialState.posts = { + [id]: getPost(id), + }; + } catch (cause) { + const error = HttpError.from<{ boundary?: string }>(cause); + if (isDevelopment()) error.expose = true; + error.data.boundary = "BlogErrorBoundary"; + throw error; + } + await state.app.render(); + }); +``` ## API @@ -17,3 +233,101 @@ In API routes, error handling involves catching and properly formatting errors, setting appropriate HTTP status codes, and potentially logging errors for debugging purposes. The framework provides utilities to streamline this process and ensure consistent error responses across your API. + +### Default Error Handling + +By default, errors thrown in API routes are caught and handled automatically. +The response body is set to an `ErrorResponse` object representing the error. +This object typically includes: + +- `status`: The HTTP status code +- `message`: A description of the error +- `data`: Additional error details (if provided) + +These errors are also logged as API route errors, which can be useful for +debugging and monitoring purposes. + +### Creating and Throwing HttpErrors + +The framework provides an `HttpError` class that you can use to create and throw +custom errors in your API routes. Here's how you can use it: + +```typescript +import { HttpError } from "@udibo/react-app"; + +// ... + +if (someErrorCondition) { + throw new HttpError(400, "Invalid input"); +} +``` + +#### Controlling Error Exposure + +You can use the `expose` property to control whether the error message is +exposed to the client: + +```typescript +throw new HttpError(400, "Invalid input", { expose: false }); +``` + +When `expose` is set to `false`, the client will receive a generic error message +instead of the specific one you provided. This is useful for hiding sensitive +information or internal error details from users. HTTP errors with a status code +of 500 or greater are not exposed to the client by default, they will only be +exposed if you explicitly set `expose` to `true`. The inverse is true for HTTP +errors with a status code between 400 and 499, they will be exposed to the +client by default, but you can set `expose` to `false` to hide them. + +Non-HTTP errors will be converted into an HttpError with a status code of 500, +with the original error being set as the cause. The cause will be logged but not +exposed to the client. + +#### Adding Additional Error Data + +You can add extra context to your errors by including additional data: + +```typescript +throw new HttpError(400, "Form validation failed", { + field: "email", + reason: "Invalid format", +}); +``` + +This additional data will be included in the `ErrorResponse` object sent to the +client. It's important to note that this data is shared with the client, so be +careful not to include any sensitive information. + +### Overriding Default Error Handling + +If you need more control over error handling, you can override the default +behavior by adding custom middleware to the root of your API routes. Here's an +example of how to do this: + +```ts +import { Router } from "@udibo/react-app/server"; +import { ErrorResponse, HttpError } from "@udibo/react-app"; +import * as log from "@std/log"; + +export default new Router() + .use(async ({ request, response }, next) => { + try { + await next(); + } catch (cause) { + const error = HttpError.from(cause); + log.error("API Error", error); + + response.status = error.status; + const extname = path.extname(request.url.pathname); + if (error.status !== 404 || extname === "") { + response.body = new ErrorResponse(error); + } + } + }); +``` + +This middleware catches any errors thrown in subsequent middleware or route +handlers. It converts the error to an `HttpError`, sets the appropriate status +code, and formats the response body. You can customize this further to fit your +specific error handling needs, such as integrating with error tracking services +or applying different handling logic based on the error type. diff --git a/docs/getting-started.md b/docs/getting-started.md index 2237557..1da39ae 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -14,6 +14,7 @@ application using the Udibo React App framework. - [routes/main.ts](#routesmaints) - [routes/main.tsx](#routesmaintsx) - [routes/index.tsx](#routesindextsx) + - [react.d.ts](#reactdts) - [Optional files](#optional-files) - [build.ts](#buildts) - [dev.ts](#devts) @@ -79,6 +80,8 @@ explains their purpose. - [routes/main.tsx](#routesmaintsx): A wrapper around the client side of the application. - [routes/index.tsx](#routesindextsx): The homepage for the application. +- [react.d.ts](#reactdts): Type definitions for React to enable autocompletion + and type checking. ### deno.jsonc @@ -103,13 +106,13 @@ Node.js-like environment. { "tasks": { // Builds the application. - "build": "deno run -A --config=deno.jsonc jsr:@udibo/react-app@0.24.2/build", + "build": "deno run -A --config=deno.jsonc jsr:@udibo/react-app@0.24.3/build", // Builds the application in development mode. "build-dev": "export APP_ENV=development NODE_ENV=development && deno task build", // Builds the application in production mode. "build-prod": "export APP_ENV=production NODE_ENV=production && deno task build", // Builds and runs the application in development mode, with hot reloading. - "dev": "export APP_ENV=development NODE_ENV=development && deno run -A --config=deno.jsonc jsr:@udibo/react-app@0.24.2/dev", + "dev": "export APP_ENV=development NODE_ENV=development && deno run -A --config=deno.jsonc jsr:@udibo/react-app@0.24.3/dev", // Runs the application. Requires the application to be built first. "run": "deno run -A ./main.ts", // Runs the application in development mode. Requires the application to be built first. @@ -132,16 +135,17 @@ Node.js-like environment. "jsxImportSourceTypes": "@types/react" }, "nodeModulesDir": true, - "lint": { - "exclude": ["public/build", "routes/_main.ts", "routes/_main.tsx"] - }, - "fmt": { - "exclude": ["public/build"] - }, + "exclude": [ + "coverage", + "node_modules", + "public/build", + "routes/_main.ts", + "routes/_main.tsx" + ], "imports": { "/": "./", "./": "./", - "@udibo/react-app": "jsr:@udibo/react-app@0.24.2", + "@udibo/react-app": "jsr:@udibo/react-app@0.24.3", "@std/assert": "jsr:@std/assert@1", "@std/log": "jsr:@std/log@0", "@std/path": "jsr:@std/path@1", @@ -344,6 +348,19 @@ export default function Index() { } ``` +### react.d.ts + +This file is required for Deno's LSP to recognize the types for React and to +provide autocompletions. + +```ts +declare module "react" { + // @ts-types="@types/react" + import React from "npm:react@18"; + export = React; +} +``` + ## Optional files - [build.ts](#buildts): Builds the application with your own build options. @@ -549,13 +566,13 @@ on: jobs: ci: name: CI - uses: udibo/react-app/.github/workflows/ci.yml@0.24.2 + uses: udibo/react-app/.github/workflows/ci.yml@0.24.3 secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} cd: name: CD needs: ci - uses: udibo/react-app/.github/workflows/deploy.yml@0.24.2 + uses: udibo/react-app/.github/workflows/deploy.yml@0.24.3 with: project: udibo-react-app-example ``` @@ -667,11 +684,13 @@ comprehensive [error handling guide](./error-handling.md). ## Metadata Metadata is crucial for improving SEO, social media sharing, and overall user -experience in your application. +experience in your application. It is also where you can add scripts and styles +to your application. For detailed information on implementing and managing metadata, including best practices and advanced techniques, please refer to our -[Metadata guide](./metadata.md). +[Metadata guide](./metadata.md). For more information on how to style your +application, please refer to the [Styling guide](./styling.md). ## Logging diff --git a/docs/routing.md b/docs/routing.md index 23f2498..94d0669 100644 --- a/docs/routing.md +++ b/docs/routing.md @@ -809,8 +809,10 @@ comprehensive [Error handling guide](./error-handling.md). ## Metadata Metadata is crucial for improving SEO, social media sharing, and overall user -experience in your application. +experience in your application. It is also where you can add scripts and styles +to your application. For detailed information on implementing and managing metadata, including best practices and advanced techniques, please refer to our -[Metadata guide](./metadata.md). +[Metadata guide](./metadata.md). For more information on how to style your +application, please refer to the [Styling guide](./styling.md). diff --git a/env.ts b/env.ts index 1a67124..751c290 100644 --- a/env.ts +++ b/env.ts @@ -106,7 +106,7 @@ export function isBrowser(): boolean { export function getEnvironment(): string { return (isServer() ? Deno.env.get("APP_ENV") - : (window as AppWindow).app?.env) ?? "development"; + : (globalThis.window as AppWindow).app?.env) ?? "development"; } /** diff --git a/error.tsx b/error.tsx index 1e66620..d167a86 100644 --- a/error.tsx +++ b/error.tsx @@ -1,12 +1,12 @@ -/** @jsxRuntime automatic */ -/** @jsxImportSource npm:react@18 */ -/** @jsxImportSourceTypes npm:@types/react@18 */ /** * This module provides utilities for handling errors in a React application. * It includes the http-error module for creating and handling HTTP errors. * * @module */ +/** @jsxRuntime automatic */ +/** @jsxImportSource npm:react@18 */ +/** @jsxImportSourceTypes npm:@types/react@18 */ import { ErrorResponse, HttpError, diff --git a/react.d.ts b/react.d.ts new file mode 100644 index 0000000..614f142 --- /dev/null +++ b/react.d.ts @@ -0,0 +1,5 @@ +declare module "react" { + // @ts-types="@types/react" + import React from "npm:react@18"; + export = React; +} diff --git a/server.tsx b/server.tsx index d44bd8c..693a1b5 100644 --- a/server.tsx +++ b/server.tsx @@ -1,12 +1,12 @@ -/** @jsxRuntime automatic */ -/** @jsxImportSource npm:react@18 */ -/** @jsxImportSourceTypes npm:@types/react@18 */ /** * This module provides the server-side functionality for the Udibo React App framework. * It has utilities for creating the server, rendering the application, and handling errors. * * @module */ +/** @jsxRuntime automatic */ +/** @jsxImportSource npm:react@18 */ +/** @jsxImportSourceTypes npm:@types/react@18 */ import * as path from "@std/path"; import * as oak from "@oak/oak"; import { StrictMode } from "react"; diff --git a/test-utils.test.ts b/test-utils.test.ts index 3e058bc..90e0d8c 100644 --- a/test-utils.test.ts +++ b/test-utils.test.ts @@ -28,7 +28,7 @@ it(startBrowserTests, "without arguments", () => { assertEquals(isTest(), true); // There is no app in the current environment. - assertEquals((window as AppWindow).app, undefined); + assertEquals((globalThis.window as AppWindow)?.app, undefined); // Simulate a new browser environment. const browser = startBrowser(); @@ -60,7 +60,7 @@ it(startBrowserTests, "without arguments", () => { assertEquals(isTest(), true); // There is no app in the current environment. - assertEquals((window as AppWindow).app, undefined); + assertEquals((globalThis.window as AppWindow)?.app, undefined); }); it(startBrowserTests, "with app argument", () => { @@ -73,7 +73,7 @@ it(startBrowserTests, "with app argument", () => { assertEquals(isTest(), true); // There is no app in the current environment. - assertEquals((window as AppWindow).app, undefined); + assertEquals((globalThis.window as AppWindow)?.app, undefined); // Simulate a new browser environment in development mode. const browser = startBrowser({ @@ -108,7 +108,7 @@ it(startBrowserTests, "with app argument", () => { assertEquals(isTest(), true); // There is no app in the current environment. - assertEquals((window as AppWindow).app, undefined); + assertEquals((globalThis.window as AppWindow)?.app, undefined); }); it(startBrowserTests, "is disposable", () => { @@ -121,7 +121,7 @@ it(startBrowserTests, "is disposable", () => { assertEquals(isTest(), true); // There is no app in the current environment. - assertEquals((window as AppWindow).app, undefined); + assertEquals((globalThis.window as AppWindow)?.app, undefined); // This function simulates a new browser environment until the function returns. function test() { @@ -157,7 +157,7 @@ it(startBrowserTests, "is disposable", () => { assertEquals(isTest(), true); // There is no app in the current environment. - assertEquals((window as AppWindow).app, undefined); + assertEquals((globalThis.window as AppWindow)?.app, undefined); }); const startEnvironmentTests = describe({ diff --git a/test-utils.ts b/test-utils.ts index f63add5..fc89bb7 100644 --- a/test-utils.ts +++ b/test-utils.ts @@ -151,14 +151,16 @@ export interface SimulatedBrowser extends Disposable { export function startBrowser< SharedState extends Record = Record, >(app?: AppData): SimulatedBrowser { - const originalApp = (window as AppWindow).app; + const originalWindow = globalThis.window; + globalThis.window = originalWindow ?? {}; + const originalApp = (globalThis.window as AppWindow).app; if (!app) { app = { env: getEnvironment(), initialState: {} as SharedState, }; } - (window as AppWindow).app = app; + (globalThis.window as AppWindow).app = app; const isServer = _env.isServer; _env.isServer = false; @@ -167,10 +169,11 @@ export function startBrowser< end(): void { _env.isServer = isServer; if (originalApp) { - (window as AppWindow).app = originalApp; + (globalThis.window as AppWindow).app = originalApp; } else { - delete (window as AppWindow).app; + delete (globalThis.window as AppWindow).app; } + globalThis.window = originalWindow; }, [Symbol.dispose]() { this.end();