Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,9 @@ jobs:
run: pnpm build

- name: Upload coverage reports
uses: codecov/codecov-action@v4
uses: codecov/codecov-action@v5
if: matrix.node-version == '20.x'
with:
files: ./coverage/coverage-final.json
fail_ci_if_error: false
files: coverage/lcov.info
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
83 changes: 65 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
<h1 align="center">React Router Dataflow</h1>

<p align="center">A tiny, fully type-safe composer for React Router loaders and actions</p>
<p align="center">A tiny, fully type-safe composer for React Router loaders, actions, and route guards.</p>
<p align="center">
<a href="https://github.com/pheleine/react-router-dataflow/actions/workflows/ci.yaml"><img src="https://github.com/pheleine/react-router-dataflow/actions/workflows/ci.yaml/badge.svg?branch=master"/></a>
<a href="https://codecov.io/gh/pheleine/react-router-dataflow"><img src="https://codecov.io/gh/pheleine/react-router-dataflow/graph/badge.svg?token=6MXCXQ7YJH"/></a>
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/TypeScript-Strict-blue"/></a>
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-MIT-brightgreen.svg"/></a>
</p>
Expand All @@ -12,8 +13,7 @@
React Router loaders and actions scale poorly once you start sharing logic.
Context becomes implicit, middleware composition is manual, and type safety quickly degrades.

React Router Dataflow fixes this by letting you compose loaders and actions as
typed middleware pipelines, producing a fully type-safe context per route.
React Router Dataflow fixes this by letting you compose loaders, actions, and route guards as typed pipelines, producing a fully type-safe execution context per route.

```typescript
import { Loader } from "react-router-dataflow";
Expand Down Expand Up @@ -65,7 +65,7 @@ const mw: LoaderMiddleware<{ data: string }> = async (args) => {
Loader.with(mw);
```

> Middlewares can also be typed action-only (`ActionMiddleware`) or universal (`Middleware`).
> Middlewares steps (for loader/action context pipelines) can also be typed action-only (`ActionMiddleware`) or universal (`Middleware`).

### Declare middlewares inline

Expand Down Expand Up @@ -128,9 +128,44 @@ Action

> For more advanced patterns such as context enforcement, parameterized middlewares and middleware factorization, see the [advanced middleware documentation](https://github.com/pheleine/react-router-dataflow/blob/master/docs/advanced_middlewares.md).

## RouteMiddleware - Guarding routes with React Router

React Router Dataflow includes support for building **React Router middlewares** with a fully typed internal pipeline, using the same `with`/`build` builder API as loaders and actions.
`RouteMiddleware` is a builder that produces a **single React Router middleware** suitable for guarding routes and short-circuiting navigation.

> `RouteMiddleware` is not related to middlewares types used by `Loader` and `Action` builders which are composing functions of loaders and actions.

`RouteMiddleware` focuses on composing route guards by letting you compose a sequence of typed steps called **route requirements**.
Each requirement runs before the route is entered, can enrich internal context, and can throw a `Response` to block navigation.
Route requirements can be predefined in the same way as [loaders and actions middlewares](#define-reusable-middlewares) using `RouteRequirement` type.

> `RouteMiddleware` does not support post-processing or response interception.
> If you need a pre/post middleware, write a React Router middleware directly and compose it alongside `RouteMiddleware`.

> *Terminology*
> - **Middleware**: a loader/action pipeline step
> - **RouteMiddleware**: a builder that produces a React Router middleware
> - **RouteRequirement**: a typed guard step executed inside a RouteMiddleware

```typescript
import { RouteMiddleware } from "react-router-dataflow";
import { requireAuth } from "~/guards/require-auth";

// Ensures user is authenticated and has "admin" role
RouteMiddleware
.with(requireAuth) // => { user }
.build(async (_, { user }) => {
if (user.role !== "admin") {
throw new Response("Unauthorized", { status: 401 });
}

return null;
});
```

## Integration in React Router

Loaders and actions created with React Router Dataflow are standard React Router data functions and behave the same way on the client and on the server.
Loaders, actions, and route middlewares created with React Router Dataflow are standard React Router primitives and behave the same way on the client and on the server.
They can be used in:
- framework mode (route files)
- data routers (`createBrowserRouter`)
Expand All @@ -139,21 +174,33 @@ They can be used in:
### Framework mode example

```tsx
// app/routes/example.tsx
// app/routes/data.$id.tsx

import { Loader, Action } from "react-router-dataflow";
import { requireAuth } from "~/middlewares/require-auth";

// Auth is enforced, no data is returned
export const loader = Loader.with(requireAuth).build();

// Auth is enforced, only PATCH/patch requests are handled
export const action = Action.with(requireAuth).build({
PATCH: async ({ request }) => {
const updatedData = /* data update using request */;
return { updatedData };
}
});
import { requireAuth } from "~/guards/require-auth";
import { dataFromParams } from "~/middlewares/data-from-params";

// Auth is enforced on the route
export const middleware = [
RouteMiddleware
.with(requireAuth)
.build()
];

// => reached only if authenticated, get data from route params
export const loader = Loader
.with(dataFromParams) // => { data }
.build(async (_, { data }) => data);

// => reached only if authenticated, only PATCH/patch requests are handled
export const action = Action
.with(dataFromParams) // => { data }
.build({
PATCH: async ({ request }, { data }) => {
const updatedData = /* data update using request */;
return updatedData;
}
});

const ExamplePage = () => <h1>Example page</h1>;

Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
{
"name": "react-router-dataflow",
"version": "1.0.1",
"version": "1.1.0",
"license": "MIT",
"type": "module",
"author": "Paulin Héleine",
"description": "A tiny, fully type-safe composer for React Router loaders and actions",
"description": "A tiny, fully type-safe composer for React Router loaders, actions, and route guards",
"sideEffects": false,
"files": [
"dist",
Expand Down
102 changes: 102 additions & 0 deletions src/data-functions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { LoaderFunctionArgs, ActionFunctionArgs } from "react-router";
import { Normalize, ContextBuilder } from "./shared-types";

type DataFunctionArgs = LoaderFunctionArgs | ActionFunctionArgs;
type DataFunction<A extends DataFunctionArgs, R> = (args: A) => Promise<Exclude<R, void | undefined>>;

type Handler<A extends DataFunctionArgs, C extends object, R> = (args: A, context: C) => Promise<Exclude<R, void | undefined>>;

type InternalMiddleware<A, R extends object | null, C extends object> = (args: A, context: C) => Promise<R>;
type LoaderMiddleware<R extends object | null, C extends object = object> = InternalMiddleware<LoaderFunctionArgs, R, C>;
type ActionMiddleware<R extends object | null, C extends object = object> = InternalMiddleware<ActionFunctionArgs, R, C>;
type Middleware<R extends object | null, C extends object = object> = InternalMiddleware<DataFunctionArgs, R, C>;

type LoaderBuilder<C extends object> = {
with<R extends object | null>(middleware: LoaderMiddleware<R, C>): LoaderBuilder<C & Normalize<R>>;
build<R>(handler: Handler<LoaderFunctionArgs, C, R>): DataFunction<LoaderFunctionArgs, R>;
build(): DataFunction<LoaderFunctionArgs, null>;
};

const createLoaderBuilder = <C extends object>(buildContext: ContextBuilder<LoaderFunctionArgs, C>): LoaderBuilder<C> => ({
with<R extends object | null>(middleware: LoaderMiddleware<R, C>) {
return createLoaderBuilder(async (args) => {
const context = await buildContext(args);
const result = await middleware(args, context);

return (result === null ? context : { ...context, ...result }) as C & Normalize<R>;
});
},

// handlers must be typed to allow its optionality. The function overloads
// ensure type safety at the call site, but any must be used to type handler.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
build(handler?: Handler<LoaderFunctionArgs, C, any>) {
return async (args: LoaderFunctionArgs) => {
const context = await buildContext(args);

return handler !== undefined ? handler(args, context) : null;
};
}
});

const Loader = createLoaderBuilder(async () => ({}));

type ActionBuilder<C extends object> = {
with<R extends object | null>(middleware: ActionMiddleware<R, C>): ActionBuilder<C & Normalize<R>>;

// Use of any is necessary to allow different return types for handlers.
// The return type of the resulting function is infered automatically from their return types.
// TypeScript cannot express a constraint that allows "any R" while still preserving
// the ability to infer the union of all handler return types.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
build<H extends Record<string, Handler<ActionFunctionArgs, C, any>>>(handlers: H): DataFunction<ActionFunctionArgs, Awaited<ReturnType<H[keyof H]>>>;
};

const createActionBuilder = <C extends object>(buildContext: ContextBuilder<ActionFunctionArgs, C>): ActionBuilder<C> => ({
with<R extends object | null>(middleware: ActionMiddleware<R, C>) {
return createActionBuilder(async (args) => {
const context = await buildContext(args);
const result = await middleware(args, context);

return (result === null ? context : { ...context, ...result }) as C & Normalize<R>;
});
},

build(handlers) {
// The return type cannot be known in advance as handlers can have different return types.
// Any is necessary because TypeScript cannot express a Record type where each value
// has a different generic type parameter (R) while maintaining type safety.
// The actual return type is correctly inferred at the call site through the overload signature.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handlersMap: Record<string, Handler<ActionFunctionArgs, C, any>> = {};

for (const [ key, handler ] of Object.entries(handlers)) {
handlersMap[key.toLowerCase()] = handler;
}

return async (args) => {
const context = await buildContext(args);
const method = args.request.method.toLowerCase();
const handler = handlersMap[method];

if (!handler) {
throw new Response("Method not allowed", { status: 405 });
}

return handler(args, context);
};
}
});

const Action = createActionBuilder(async () => ({}));

export {
Loader,
Action
};

export type {
Middleware,
LoaderMiddleware,
ActionMiddleware
};
109 changes: 7 additions & 102 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,110 +1,15 @@
import type { LoaderFunctionArgs, ActionFunctionArgs } from "react-router";

type DataFunctionArgs = LoaderFunctionArgs | ActionFunctionArgs;
type DataFunction<A extends DataFunctionArgs, R> = (args: A) => Promise<Exclude<R, void | undefined>>;

type Handler<A extends DataFunctionArgs, C extends object, R> = (args: A, context: C) => Promise<Exclude<R, void | undefined>>;

type InternalMiddleware<A, R extends object | null, C extends object> = (args: A, context: C) => Promise<R>;
type LoaderMiddleware<R extends object | null, C extends object = object> = InternalMiddleware<LoaderFunctionArgs, R, C>;
type ActionMiddleware<R extends object | null, C extends object = object> = InternalMiddleware<ActionFunctionArgs, R, C>;
type Middleware<R extends object | null, C extends object = object> = InternalMiddleware<DataFunctionArgs, R, C>;

type ContextBuilder<A extends DataFunctionArgs, C> = (args: A) => Promise<C>;

type Normalize<R> = R extends null ? object : R;

type LoaderBuilder<C extends object> = {
with<R extends object | null>(middleware: LoaderMiddleware<R, C>): LoaderBuilder<C & Normalize<R>>;
build<R>(handler: Handler<LoaderFunctionArgs, C, R>): DataFunction<LoaderFunctionArgs, R>;
build(): DataFunction<LoaderFunctionArgs, null>;
};

const createLoaderBuilder = <C extends object>(buildContext: ContextBuilder<LoaderFunctionArgs, C>): LoaderBuilder<C> => ({
with<R extends object | null>(middleware: LoaderMiddleware<R, C>) {
return createLoaderBuilder(async (args) => {
const context = await buildContext(args);
const result = await middleware(args, context);

return (result === null ? context : { ...context, ...result }) as C & Normalize<R>;
});
},

// handlers must be typed to allow its optionality. The function overloads
// ensure type safety at the call site, but any must be used to type handler.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
build(handler?: Handler<LoaderFunctionArgs, C, any>) {
if (handler === undefined) {
return async () => null;
}

return async (args: LoaderFunctionArgs) => {
const context = await buildContext(args);

return handler(args, context);
};
}
});

const Loader = createLoaderBuilder(async () => ({}));

type ActionBuilder<C extends object> = {
with<R extends object | null>(middleware: ActionMiddleware<R, C>): ActionBuilder<C & Normalize<R>>;

// Use of any is necessary to allow different return types for handlers.
// The return type of the resulting function is infered automatically from their return types.
// TypeScript cannot express a constraint that allows "any R" while still preserving
// the ability to infer the union of all handler return types.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
build<H extends Record<string, Handler<ActionFunctionArgs, C, any>>>(handlers: H): DataFunction<ActionFunctionArgs, Awaited<ReturnType<H[keyof H]>>>;
};

const createActionBuilder = <C extends object>(buildContext: ContextBuilder<ActionFunctionArgs, C>): ActionBuilder<C> => ({
with<R extends object | null>(middleware: ActionMiddleware<R, C>) {
return createActionBuilder(async (args) => {
const context = await buildContext(args);
const result = await middleware(args, context);

return (result === null ? context : { ...context, ...result }) as C & Normalize<R>;
});
},

build(handlers) {
// The return type cannot be known in advance as handlers can have different return types.
// Any is necessary because TypeScript cannot express a Record type where each value
// has a different generic type parameter (R) while maintaining type safety.
// The actual return type is correctly inferred at the call site through the overload signature.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handlersMap: Record<string, Handler<ActionFunctionArgs, C, any>> = {};

for (const [ key, handler ] of Object.entries(handlers)) {
handlersMap[key.toLowerCase()] = handler;
}

return async (args) => {
const method = args.request.method.toLowerCase();
const handler = handlersMap[method];

if (!handler) {
throw new Response("Method not allowed", { status: 405 });
}

const context = await buildContext(args);

return handler(args, context);
};
}
});

const Action = createActionBuilder(async () => ({}));
import { Loader, Action, LoaderMiddleware, ActionMiddleware, Middleware } from "./data-functions";
import { RouteMiddleware, RouteRequirement } from "./route-middleware";

export {
Loader,
Action
Action,
RouteMiddleware
};

export type {
Middleware,
LoaderMiddleware,
ActionMiddleware
ActionMiddleware,
Middleware,
RouteRequirement
};
Loading