├── .github └── workflows │ ├── github-publish.yml │ └── npm-publish.yml ├── .gitignore ├── .husky └── pre-commit ├── .vscode └── launch.json ├── README.md ├── example ├── main │ ├── .gitignore │ ├── .prettierrc │ ├── README.md │ ├── nest-cli.json │ ├── package.json │ ├── react-router.config.ts │ ├── src │ │ ├── common │ │ │ ├── global.exception.filter.ts │ │ │ ├── global.interceptor.ts │ │ │ ├── global.response.ts │ │ │ ├── react-router.exceptions.ts │ │ │ ├── test.decorator.ts │ │ │ └── user.auth.guard.ts │ │ ├── entry.client.tsx │ │ ├── entry.server.tsx │ │ ├── main.ts │ │ ├── modules │ │ │ └── app │ │ │ │ ├── app.controller.ts │ │ │ │ ├── app.module.ts │ │ │ │ ├── app.service.ts │ │ │ │ └── dto │ │ │ │ └── login.dto.ts │ │ ├── root.tsx │ │ ├── routes.ts │ │ └── routes │ │ │ ├── _index.tsx │ │ │ ├── foo.tsx │ │ │ └── server │ │ │ ├── foo.server.ts │ │ │ └── index.server.ts │ ├── tsconfig.build.json │ ├── tsconfig.json │ ├── tsconfig.nest.json │ └── vite.config.mts └── microservices │ ├── .eslintrc.js │ ├── .gitignore │ ├── .prettierrc │ ├── README.md │ ├── nest-cli.json │ ├── package.json │ ├── src │ ├── app.controller.ts │ ├── app.module.ts │ ├── app.service.ts │ └── main.ts │ ├── tsconfig.build.json │ └── tsconfig.json ├── package.json ├── packages ├── nest-react-router │ └── package.json └── nestjs-remix │ └── package.json ├── rollup.config.mjs ├── src ├── client │ ├── helper.ts │ ├── index.ts │ └── usePromiseSubmit.ts ├── index.ts └── server │ ├── index.ts │ ├── remix.constant.ts │ ├── remix.core.ts │ ├── remix.decorator.ts │ ├── remix.exceptions.ts │ ├── remix.helper.ts │ ├── remix.middleware.ts │ ├── remix.resolve.services.ts │ ├── remix.route.params.factory.ts │ ├── remix.service.ts │ └── remix.type.d.ts ├── synchronous-version.js └── tsconfig.json /.github/workflows/github-publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish github package 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | 11 | permissions: 12 | contents: read 13 | id-token: write 14 | 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v4 18 | 19 | - name: Setup node 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: "20.x" 23 | registry-url: "https://npm.pkg.github.com" 24 | 25 | - name: Install dependencies 26 | run: npm install 27 | 28 | - name: Build 29 | run: npm run build 30 | 31 | - name: Publish to Github packages 32 | run: cd nestjs-remix && npm publish --provenance --access public --registry=https://npm.pkg.github.com/ 33 | env: 34 | NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 35 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish npm package 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | 11 | permissions: 12 | contents: read 13 | id-token: write 14 | 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v4 18 | 19 | - name: Setup node 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: "20.x" 23 | 24 | - name: Install dependencies 25 | run: npm install 26 | 27 | - name: Authenticate with the npm Registry 28 | run: echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc 29 | env: 30 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 31 | 32 | - name: Publish to npm 33 | run: npm run publish 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /node_modules 3 | packages/nestjs-remix/* 4 | !packages/nestjs-remix/package.json 5 | packages/nest-react-router/* 6 | !packages/nest-react-router/package.json 7 | example/vite.config.mts.* 8 | 9 | # lock file 10 | *.lock 11 | *.lockb 12 | 13 | # yarn 14 | .yarn/ 15 | .yarnrc.yml -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npm run synchronous-version 2 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "attach", 10 | "name": "Attach NestJS WS", 11 | "port": 9229, 12 | "restart": true, 13 | "stopOnEntry": false, 14 | "protocol": "inspector" 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Welcome to nest-react-router! 2 | 3 |
8 | 9 |A progressive Node.js framework for building efficient and scalable server-side applications.
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
{message}
45 | 46 |A progressive Node.js framework for building efficient and scalable server-side applications.
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
, 19 | boolean, 20 | ] { 21 | const { delay = 0 } = options ?? {}; 22 | const submit = useSubmit(); 23 | const actionData = useActionData
(); 24 | const $deferred = useRef(deferred
());
25 | const nextCanActiveTs = useRef => {
29 | if (nextCanActiveTs.current && Date.now() < nextCanActiveTs.current) {
30 | return Promise.reject();
31 | }
32 | setLoading(true);
33 | nextCanActiveTs.current = Date.now() + delay;
34 | if (!(args[0] instanceof FormData)) {
35 | args[0] = serialize(args[0], {
36 | indices: true,
37 | noFilesWithArrayNotation: true,
38 | });
39 | }
40 | submit.apply(null, args);
41 | return $deferred.current.promise;
42 | },
43 | [submit]
44 | );
45 | useEffect(() => {
46 | if (actionData) {
47 | function resolve() {
48 | $deferred.current.resolve(actionData as P);
49 | $deferred.current = deferred();
50 | setLoading(false);
51 | }
52 | if (nextCanActiveTs.current && Date.now() < nextCanActiveTs.current) {
53 | setTimeout(resolve, nextCanActiveTs.current - Date.now());
54 | } else {
55 | resolve();
56 | }
57 | }
58 | }, [actionData]);
59 | return [_submit, loading];
60 | }
61 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import type { AppLoadContext, Params } from "react-router";
2 | import type { LoaderFunctionArgs, ActionFunctionArgs } from "react-router";
3 | import type { RemixService } from "server/remix.service";
4 | import type { ServeStaticOptions } from "@nestjs/platform-express/interfaces/serve-static-options.interface";
5 | import type * as core from "express-serve-static-core";
6 | import path from "path";
7 |
8 | export {
9 | Loader,
10 | Action,
11 | useAction,
12 | useLoader,
13 | useServer,
14 | ReactRouterArgs,
15 | ReactRouterArgs as RemixArgs,
16 | resolveReactRouterServices,
17 | resolveReactRouterServices as resolveRemixServices,
18 | startNestReactRouter,
19 | startNestReactRouter as startNestRemix,
20 | type ReactRouterError,
21 | ReactRouterException,
22 | type ReactRouterError as RemixError,
23 | ReactRouterException as RemixException,
24 | } from "./server";
25 |
26 | export interface ReactRouterLoadContext extends AppLoadContext {
27 | moduleKey: string;
28 | moduleRef: RemixService;
29 | req: Request;
30 | res: Response;
31 | next: NextFunction;
32 | }
33 |
34 | export type ReactRouterConfig = {
35 | clientDir: string;
36 | serverFile: string;
37 | clientFileOptions: ServeStaticOptions;
38 | };
39 |
40 | export const defaultRemixConfig: ReactRouterConfig = {
41 | clientDir: path.join(process.cwd(), "/build/client"),
42 | serverFile: path.join(process.cwd(), "/build/server/index.mjs"),
43 | clientFileOptions: { immutable: true, maxAge: "1d" },
44 | };
45 |
46 | declare global {
47 | namespace Express {
48 | interface Request {
49 | handleByReactRouter?: boolean;
50 | reactRouterArgs?: LoaderFunctionArgs | ActionFunctionArgs;
51 | reactRouterParams?: Params;
52 | }
53 | }
54 |
55 | interface Request extends core.Request {}
56 |
57 | interface Response extends core.Response {}
58 |
59 | type NextFunction = core.NextFunction;
60 | }
61 |
--------------------------------------------------------------------------------
/src/server/index.ts:
--------------------------------------------------------------------------------
1 | export { resolveReactRouterServices } from "./remix.resolve.services";
2 | export { Loader, Action, ReactRouterArgs } from "./remix.decorator";
3 | export {
4 | startNestReactRouter,
5 | useAction,
6 | useLoader,
7 | useServer,
8 | } from "./remix.core";
9 | export {
10 | type ReactRouterError,
11 | ReactRouterException,
12 | } from "./remix.exceptions";
13 |
--------------------------------------------------------------------------------
/src/server/remix.constant.ts:
--------------------------------------------------------------------------------
1 | export const ReactRouterParamtypes = {
2 | REACT_ROUTER_ARGS: Number.MAX_SAFE_INTEGER - 888,
3 | };
4 |
--------------------------------------------------------------------------------
/src/server/remix.core.ts:
--------------------------------------------------------------------------------
1 | import type { NestApplication } from "@nestjs/core";
2 | import type { Type } from "@nestjs/common";
3 | import type {
4 | LoaderFunction,
5 | ActionFunction,
6 | LoaderFunctionArgs,
7 | ActionFunctionArgs,
8 | } from "react-router";
9 | import type { NestContainer } from "@nestjs/core/injector/container";
10 | import type { ReactRouterLoadContext, ReactRouterConfig } from "../index";
11 | import type { ViteDevServer } from "vite";
12 | import { ExternalContextCreator } from "@nestjs/core";
13 | import { RequestMethod } from "@nestjs/common/enums";
14 | import { IS_DEV, isConstructor } from "./remix.helper";
15 | import { remixMiddleware } from "./remix.middleware";
16 | import { defaultRemixConfig, ReactRouterException } from "../index";
17 | import { ROUTE_ARGS_METADATA } from "@nestjs/common/constants";
18 | import { RemixRouteParamsFactory } from "./remix.route.params.factory";
19 | import bodyParser from "body-parser";
20 |
21 | export enum RemixProperty {
22 | Loader = "Loader",
23 | ActionPost = "Action.Post",
24 | ActionPut = "Action.Put",
25 | ActionPatch = "Action.Patch",
26 | ActionDelete = "Action.Delete",
27 | ActionOptions = "Action.Options",
28 | ActionHead = "Action.Head",
29 | ActionSearch = "Action.Search",
30 | }
31 |
32 | export let viteDevServer: ViteDevServer;
33 | let remixExecutionContextCreator: ExternalContextCreator;
34 | const remixRouteParamsFactory = new RemixRouteParamsFactory();
35 |
36 | const getProviderName = (type: Type | string) =>
37 | typeof type === "string" ? type : type.name;
38 |
39 | const getPropertyNameByRequest = (
40 | request: Request
41 | ): [RemixProperty, RequestMethod] => {
42 | switch (request.method) {
43 | case "GET":
44 | return [RemixProperty.Loader, RequestMethod.GET];
45 | case "POST":
46 | return [RemixProperty.ActionPost, RequestMethod.POST];
47 | case "PUT":
48 | return [RemixProperty.ActionPut, RequestMethod.PUT];
49 | case "PATCH":
50 | return [RemixProperty.ActionPatch, RequestMethod.PATCH];
51 | case "DELETE":
52 | return [RemixProperty.ActionDelete, RequestMethod.DELETE];
53 | case "OPTIONS":
54 | return [RemixProperty.ActionOptions, RequestMethod.OPTIONS];
55 | case "HEAD":
56 | return [RemixProperty.ActionHead, RequestMethod.HEAD];
57 | case "SEARCH":
58 | return [RemixProperty.ActionSearch, RequestMethod.SEARCH];
59 | }
60 | };
61 |
62 | type RemixDescriptor = Partial<{
63 | [key in RemixProperty]: string;
64 | }>;
65 |
66 | export const setRemixTypeDescriptor = (
67 | type: Type,
68 | methodName: string,
69 | property: RemixProperty
70 | ) =>
71 | Reflect.defineMetadata(
72 | "__RemixTypeDescriptor__",
73 | {
74 | ...getRemixTypeDescriptor(type),
75 | [property]: methodName,
76 | },
77 | type
78 | );
79 |
80 | const getRemixTypeDescriptor = (type: Type): RemixDescriptor | undefined =>
81 | Reflect.getMetadata("__RemixTypeDescriptor__", type);
82 |
83 | type ReturnFunction = LoaderFunction | ActionFunction;
84 |
85 | const useDecorator = (
86 | typeOrDescriptor: Type | RemixDescriptor,
87 | typeName?: string
88 | ): ReturnFunction => {
89 | if (isConstructor(typeOrDescriptor)) {
90 | const descriptor = getRemixTypeDescriptor(typeOrDescriptor);
91 | return useDecorator(descriptor, typeName ?? typeOrDescriptor.name);
92 | }
93 | return async (args: LoaderFunctionArgs | ActionFunctionArgs) => {
94 | const { moduleRef, req, res, next } =
95 | args.context as ReactRouterLoadContext;
96 | req.reactRouterArgs = args;
97 | req.reactRouterParams = args.params;
98 | const providerName = getProviderName(typeName);
99 | const instance = moduleRef.get(providerName);
100 | const [requestProperty, requestMethod] = getPropertyNameByRequest(
101 | args.context.req as Request
102 | );
103 | const methodName = typeOrDescriptor[requestProperty];
104 |
105 | if (!methodName) {
106 | return new ReactRouterException(
107 | `No method found using @${requestProperty} decorator on class ${providerName}`
108 | ).toResponse();
109 | }
110 |
111 | const _remixExecutionContextCreator: ExternalContextCreator =
112 | remixExecutionContextCreator ?? global.remixExecutionContext;
113 |
114 | const executionContext = await _remixExecutionContextCreator.create(
115 | instance,
116 | instance[methodName],
117 | methodName,
118 | ROUTE_ARGS_METADATA,
119 | remixRouteParamsFactory
120 | );
121 |
122 | return executionContext(req, res, next);
123 | };
124 | };
125 |
126 | export const startNestReactRouter = async (
127 | app: NestApplication,
128 | remixConfig: ReactRouterConfig = defaultRemixConfig
129 | ) => {
130 | // client static file middleware
131 | app.useStaticAssets(remixConfig.clientDir, remixConfig.clientFileOptions);
132 |
133 | // vite middleware (DEV only)
134 | if (IS_DEV) {
135 | if (!viteDevServer) {
136 | viteDevServer = await import("vite").then((vite) =>
137 | vite.createServer({
138 | server: { middlewareMode: true },
139 | })
140 | );
141 | app.use(viteDevServer.middlewares);
142 | }
143 | }
144 |
145 | // remix middleware
146 | app.use(
147 | bodyParser.urlencoded({ extended: true }),
148 | await remixMiddleware(app, remixConfig)
149 | );
150 |
151 | const container = (app as any).container as NestContainer;
152 |
153 | remixExecutionContextCreator =
154 | ExternalContextCreator.fromContainer(container);
155 |
156 | if (IS_DEV) {
157 | global.remixExecutionContext = remixExecutionContextCreator;
158 | }
159 | };
160 |
161 | /**
162 | * @deprecated Use `useServer()` instead.
163 | */
164 | export const useLoader = (type: Type) => useDecorator(type) as LoaderFunction;
165 | /**
166 | * @deprecated Use `useServer()` instead.
167 | */
168 | export const useAction = (type: Type) => useDecorator(type) as ActionFunction;
169 |
170 | export const useServer = (type: Type) =>
171 | useDecorator(type) as LoaderFunction | ActionFunction;
172 |
--------------------------------------------------------------------------------
/src/server/remix.decorator.ts:
--------------------------------------------------------------------------------
1 | import type { ParamData } from "@nestjs/common";
2 | import { assignMetadata } from "@nestjs/common";
3 | import { ROUTE_ARGS_METADATA } from "@nestjs/common/constants";
4 | import { RemixProperty, setRemixTypeDescriptor } from "./remix.core";
5 | import { isConstructor } from "./remix.helper";
6 | import { ReactRouterParamtypes } from "./remix.constant";
7 |
8 | function createRouteParamDecorator(paramtype: number) {
9 | return (data?: ParamData): ParameterDecorator =>
10 | (target, key, index) => {
11 | if (!key) return;
12 | const args =
13 | Reflect.getMetadata(ROUTE_ARGS_METADATA, target.constructor, key) || {};
14 | Reflect.defineMetadata(
15 | ROUTE_ARGS_METADATA,
16 | assignMetadata(args, paramtype, index, data),
17 | target.constructor,
18 | key
19 | );
20 | };
21 | }
22 |
23 | export const ReactRouterArgs = createRouteParamDecorator(
24 | ReactRouterParamtypes.REACT_ROUTER_ARGS
25 | );
26 |
27 | function Decorator(...properties: RemixProperty[]) {
28 | return (target: any, propertyKey: string, descriptor: PropertyDescriptor) => {
29 | const type = target.constructor;
30 | if (!isConstructor(type)) {
31 | return;
32 | }
33 | for (const property of properties) {
34 | setRemixTypeDescriptor(type, propertyKey, property);
35 | }
36 | };
37 | }
38 |
39 | export const Loader = () => Decorator(RemixProperty.Loader);
40 | export function Action() {
41 | return Decorator(RemixProperty.ActionPost);
42 | }
43 | Action.Post = () => Action;
44 | Action.Put = () => Decorator(RemixProperty.ActionPut);
45 | Action.Patch = () => Decorator(RemixProperty.ActionPatch);
46 | Action.Delete = () => Decorator(RemixProperty.ActionDelete);
47 | Action.Options = () => Decorator(RemixProperty.ActionOptions);
48 | Action.Head = () => Decorator(RemixProperty.ActionHead);
49 | Action.Search = () => Decorator(RemixProperty.ActionSearch);
50 |
--------------------------------------------------------------------------------
/src/server/remix.exceptions.ts:
--------------------------------------------------------------------------------
1 | export interface ReactRouterError {
2 | data: {
3 | message?: string;
4 | code?: number;
5 | success?: false;
6 | };
7 | internal: boolean;
8 | status: number;
9 | statusText: string;
10 | }
11 |
12 | export class ReactRouterException extends Error {
13 | message: string;
14 |
15 | code: number;
16 |
17 | constructor(message?: string, code?: number) {
18 | super(message);
19 | this.message = message ?? "Internal Server Error";
20 | this.code = code ?? 500;
21 | }
22 |
23 | toResponse() {
24 | return new Response(
25 | JSON.stringify({
26 | message: this.message,
27 | code: this.code,
28 | success: false,
29 | }),
30 | {
31 | status: this.code,
32 | statusText: this.message,
33 | headers: {
34 | "Content-Type": "application/json",
35 | },
36 | }
37 | );
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/server/remix.helper.ts:
--------------------------------------------------------------------------------
1 | import type { Type } from "@nestjs/common";
2 | import { matchRoutes } from "react-router";
3 | import { pathToFileURL } from "url";
4 | import awaitImport from "await-import-dont-compile";
5 | import {
6 | AgnosticRouteObject,
7 | ServerRoute,
8 | ServerRouteManifest,
9 | RouteMatch,
10 | } from "./remix.type";
11 |
12 | export const IS_DEV = process.env.NODE_ENV !== "production";
13 |
14 | export const isConstructor = (type: any): type is Type => {
15 | try {
16 | new type();
17 | } catch (err) {
18 | if ((err as Error).message.indexOf("is not a constructor") > -1) {
19 | return false;
20 | }
21 | }
22 | return true;
23 | };
24 |
25 | export const dynamicImport = async (filepath: string) => {
26 | let href = pathToFileURL(filepath).href;
27 | // dynamic import have a cache, will cause the remix page not to be updated
28 | if (IS_DEV) {
29 | href += `?d=${Date.now()}`;
30 | }
31 | return await awaitImport(href);
32 | };
33 |
34 | export const delay = (ms: number) => {
35 | return new Promise