Skip to content

Commit 09ea524

Browse files
committed
feat: add middleware composition to loaders & actions
1 parent c62846b commit 09ea524

File tree

8 files changed

+275
-12
lines changed

8 files changed

+275
-12
lines changed
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export interface MiddlewareConfig {
2+
GlobalMiddlewares: readonly [];
3+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
export type Expand<T> = T extends object
2+
? T extends infer O
3+
? O extends Function
4+
? O
5+
: {
6+
[K in keyof O]: O[K];
7+
}
8+
: never
9+
: T;
10+
11+
export type DataReturnType<Data, TContext> = {
12+
data: Data;
13+
__context: TContext;
14+
__headers: HeaderEntry[];
15+
};
16+
17+
export type HeaderEntry = [string, string];
18+
19+
export type NextFunction = <TOutContext = undefined>(ctx?: {
20+
context: TOutContext;
21+
headers?: HeaderEntry[];
22+
}) => Promise<DataReturnType<any, TOutContext>>;
23+
24+
export type DataFunctionArgs<TInContext> = {
25+
request: Request;
26+
params: Record<string, string>;
27+
context: TInContext;
28+
};
29+
30+
export type MiddlewareFunction<in out TInContext = any, TOutContext = any> = (
31+
args: DataFunctionArgs<TInContext>,
32+
next: NextFunction,
33+
) => Promise<DataReturnType<any, TOutContext>>;
34+
35+
export type MiddlewareObject<
36+
TDependencies extends readonly MiddlewareObject[] = any,
37+
TOutContext = any,
38+
> = {
39+
deps: readonly [...TDependencies];
40+
fn: MiddlewareFunction<Expand<MergeMiddlewareContext<TDependencies>>, TOutContext>;
41+
};
42+
43+
export type MergeMiddlewareContext<T extends readonly MiddlewareObject[]> = T extends readonly [
44+
infer M,
45+
]
46+
? M extends MiddlewareObject<any, infer TOutContext>
47+
? TOutContext
48+
: never
49+
: T extends readonly [infer M, ...infer R extends readonly MiddlewareObject[]]
50+
? M extends MiddlewareObject<any, infer TOutContext>
51+
? TOutContext & MergeMiddlewareContext<R>
52+
: never
53+
: {};
54+
55+
export type ExecutionEnvironmentQueueItem = {
56+
data: { context: any } | undefined;
57+
};
58+
59+
export type ExecutionEnvironment = {
60+
queue: Map<Function, ExecutionEnvironmentQueueItem>;
61+
ctx: {
62+
request: Request;
63+
params: Record<string, string>;
64+
context: Record<string, unknown>;
65+
};
66+
};
67+
68+
export type DataWithOptions<Data> = {
69+
__dataObject: true;
70+
data: Data;
71+
headers?: HeaderEntry[];
72+
};
73+
74+
export type ServerFunction<TContext, Data> = (
75+
args: DataFunctionArgs<TContext>,
76+
) => Promise<Data | DataWithOptions<Data>>;
77+
78+
export type RemixDataFunctionArgs = {
79+
request: Request;
80+
params: Record<string, string>;
81+
};
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
export type GlobalMiddlewares =
2+
import('@app-builder/core/middleware-config').MiddlewareConfig['GlobalMiddlewares'];
3+
4+
import type {
5+
DataReturnType,
6+
ExecutionEnvironment,
7+
ExecutionEnvironmentQueueItem,
8+
Expand,
9+
MergeMiddlewareContext,
10+
MiddlewareFunction,
11+
MiddlewareObject,
12+
NextFunction,
13+
RemixDataFunctionArgs,
14+
ServerFunction,
15+
} from './middleware-types';
16+
17+
let globalMiddlewares: readonly MiddlewareObject[] = [];
18+
19+
function createMiddlewareCallbable(
20+
object: MiddlewareObject,
21+
env: ExecutionEnvironment,
22+
globalEnv: ExecutionEnvironment | undefined,
23+
next: NextFunction,
24+
) {
25+
// Create a env specific to middleware queue to not pollute the global request one
26+
const middlewareEnv = createExecutionEnvironment(env);
27+
middlewareEnv.ctx.context = { ...globalEnv?.ctx.context };
28+
29+
const finalMiddleware: NextFunction = async (args): Promise<DataReturnType<any, any>> => {
30+
middlewareEnv.ctx.context = { ...middlewareEnv.ctx.context, ...args?.context };
31+
return object.fn(middlewareEnv.ctx, next);
32+
};
33+
34+
return createMiddlewareChain(object.deps, middlewareEnv, globalEnv, finalMiddleware);
35+
}
36+
37+
function createServerFunctionCallable(
38+
middlewares: readonly MiddlewareObject[],
39+
env: ExecutionEnvironment,
40+
fn: ServerFunction<any, any>,
41+
) {
42+
const final: NextFunction = async (args): Promise<DataReturnType<any, any>> => {
43+
env.ctx.context = { ...env.ctx.context, ...args?.context };
44+
const res = await fn(env.ctx);
45+
const data =
46+
res instanceof Object && '__dataObject' in res && res.__dataObject ? res.data : res;
47+
48+
return {
49+
data,
50+
__context: env.ctx.context,
51+
__headers: [...(args?.headers ?? []), ...(res.headers ?? [])],
52+
};
53+
};
54+
55+
const globalEnv = createExecutionEnvironment(env);
56+
57+
const routeMiddlewareChain = createMiddlewareChain(middlewares, env, globalEnv, final);
58+
const globalMiddlewareChain = createMiddlewareChain(
59+
globalMiddlewares,
60+
globalEnv,
61+
undefined,
62+
(nextArgs) => {
63+
globalEnv.ctx.context = { ...globalEnv.ctx.context, ...nextArgs?.context };
64+
return routeMiddlewareChain();
65+
},
66+
);
67+
68+
return globalMiddlewareChain;
69+
}
70+
71+
function createMiddlewareChain(
72+
middlewares: readonly MiddlewareObject[],
73+
env: ExecutionEnvironment,
74+
globalEnv: ExecutionEnvironment | undefined,
75+
final: NextFunction,
76+
): NextFunction {
77+
return middlewares.reduceRight<NextFunction>((nextFn, middleware) => {
78+
return async (args) => {
79+
env.ctx.context = { ...env.ctx.context, ...args?.context };
80+
81+
const queueItem = env.queue.get(middleware.fn);
82+
if (queueItem) {
83+
return nextFn(queueItem.data);
84+
}
85+
86+
const callable = createMiddlewareCallbable(middleware, env, globalEnv, (nextArgs) => {
87+
env.queue.set(middleware.fn, { data: nextArgs });
88+
return nextFn(nextArgs);
89+
});
90+
91+
return callable();
92+
};
93+
}, final);
94+
}
95+
96+
function createExecutionEnvironment(
97+
...args: [ExecutionEnvironment] | [Request, Record<string, string>]
98+
): ExecutionEnvironment {
99+
if (args[0] instanceof Request) {
100+
return {
101+
queue: new Map<Function, ExecutionEnvironmentQueueItem>(),
102+
ctx: { context: {}, request: args[0], params: args[1] ?? {} },
103+
};
104+
}
105+
106+
return {
107+
queue: args[0].queue,
108+
ctx: { ...args[0].ctx, context: {} },
109+
};
110+
}
111+
112+
function buildResponse(ret: DataReturnType<any, any>) {
113+
const headers = new Headers();
114+
for (const [key, value] of ret.__headers) {
115+
headers.append(key, value);
116+
}
117+
return Response.json(ret.data, { headers });
118+
}
119+
120+
export function createServerFn<T extends readonly MiddlewareObject[], Ret>(
121+
middlewares: readonly [...T],
122+
fn: ServerFunction<Expand<MergeMiddlewareContext<[...GlobalMiddlewares, ...T]>>, Ret>,
123+
) {
124+
return async (args: RemixDataFunctionArgs): Promise<Response> => {
125+
const env = createExecutionEnvironment(args.request, args.params);
126+
const middlewareChain = createServerFunctionCallable(middlewares, env, fn);
127+
128+
const ret = await middlewareChain();
129+
return buildResponse(ret);
130+
};
131+
}
132+
133+
export function createMiddleware<T extends readonly MiddlewareObject[], TOutContext>(
134+
middlewares: readonly [...T],
135+
fn: MiddlewareFunction<Expand<MergeMiddlewareContext<T>>, TOutContext>,
136+
): MiddlewareObject<any, TOutContext> {
137+
return { deps: middlewares, fn: fn as any };
138+
}
139+
140+
export function createMiddlewareWithGlobalContext<
141+
T extends readonly MiddlewareObject[],
142+
TOutContext,
143+
>(
144+
middlewares: readonly [...T],
145+
fn: MiddlewareFunction<Expand<MergeMiddlewareContext<[...GlobalMiddlewares, ...T]>>, TOutContext>,
146+
): MiddlewareObject<any, TOutContext> {
147+
return createMiddleware(middlewares, fn as any);
148+
}
149+
150+
export function setGlobalMiddlewares<T extends readonly MiddlewareObject[]>(...middlewares: T) {
151+
globalMiddlewares = middlewares;
152+
return middlewares;
153+
}

packages/app-builder/src/entry.server.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { initServerServices } from './services/init.server';
1616
import { captureUnexpectedRemixError } from './services/monitoring';
1717
import { checkEnv, getClientEnvVars, getServerEnv } from './utils/environment';
1818
import { NonceProvider } from './utils/nonce';
19+
import './global-middlewares';
1920

2021
const ABORT_DELAY = 70000;
2122

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { setGlobalMiddlewares } from '@app-builder/core/requests';
2+
import { servicesMiddleware } from './middlewares/services-middleware';
3+
4+
export const globalMiddlewares = setGlobalMiddlewares(servicesMiddleware);
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { createMiddleware } from '@app-builder/core/requests';
2+
import { initServerServices } from '@app-builder/services/init.server';
3+
4+
export const servicesMiddleware = createMiddleware(
5+
[],
6+
async function servicesMiddleware({ request }, next) {
7+
const services = initServerServices(request);
8+
return next({ context: { services } });
9+
},
10+
);

packages/app-builder/src/routes/_builder+/lists+/_index.tsx

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
import { ErrorComponent, Page } from '@app-builder/components';
22
import { BreadCrumbs } from '@app-builder/components/Breadcrumbs';
33
import { CreateListModal } from '@app-builder/components/Lists/CreateListModal';
4+
import { createMiddlewareWithGlobalContext, createServerFn } from '@app-builder/core/requests';
45
import { type CustomList } from '@app-builder/models/custom-list';
56
import { isCreateListAvailable } from '@app-builder/services/feature-access';
6-
import { initServerServices } from '@app-builder/services/init.server';
77
import { getRoute } from '@app-builder/utils/routes';
88
import { fromUUIDtoSUUID } from '@app-builder/utils/short-uuid';
9-
import { type LoaderFunctionArgs } from '@remix-run/node';
109
import { Link, useLoaderData, useRouteError } from '@remix-run/react';
1110
import { captureRemixErrorBoundaryError } from '@sentry/remix';
1211
import { createColumnHelper, getCoreRowModel, getSortedRowModel } from '@tanstack/react-table';
@@ -15,18 +14,25 @@ import { useMemo } from 'react';
1514
import { useTranslation } from 'react-i18next';
1615
import { Table, useVirtualTable } from 'ui-design-system';
1716

18-
export async function loader({ request }: LoaderFunctionArgs) {
19-
const { authService } = initServerServices(request);
20-
const { user, customListsRepository } = await authService.isAuthenticated(request, {
21-
failureRedirect: getRoute('/sign-in'),
22-
});
17+
const authMiddleware = createMiddlewareWithGlobalContext(
18+
[],
19+
async function authMiddleware({ request, context }, next) {
20+
console.log('authMiddleware', [context]);
21+
const repositories = await context.services.authService.isAuthenticated(request, {
22+
failureRedirect: getRoute('/sign-in'),
23+
});
24+
25+
return next({ context: { repositories } });
26+
},
27+
);
28+
29+
export const loader = createServerFn([authMiddleware], async ({ request, context }) => {
30+
const { user, customListsRepository } = context.repositories;
31+
2332
const customLists = await customListsRepository.listCustomLists();
2433

25-
return Response.json({
26-
customLists,
27-
isCreateListAvailable: isCreateListAvailable(user),
28-
});
29-
}
34+
return { customLists, isCreateListAvailable: isCreateListAvailable(user) };
35+
});
3036

3137
export const handle = {
3238
i18n: ['lists', 'navigation'] satisfies Namespace,
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
declare module '@app-builder/core/middleware-config' {
2+
interface MiddlewareConfig {
3+
GlobalMiddlewares: typeof import('../src/global-middlewares').globalMiddlewares;
4+
}
5+
}

0 commit comments

Comments
 (0)