///
// `dom.iterable` types are explicitly required for extracting `FormData` values,
// as all implementations of `Symbol.iterable` are separated from the main `dom` types.
// Using triple-slash directive makes sure that it will be available,
// even if end-user `tsconfig.json` omits it in the `lib` array.
import { observable, tap } from '@trpc/server/observable';
import type {
AnyRouter,
InferrableClientTypes,
} from '@trpc/server/unstable-core-do-not-import';
import type { TRPCClientError } from '../TRPCClientError';
import type { Operation, OperationResultEnvelope, TRPCLink } from './types';
type ConsoleEsque = {
log: (...args: any[]) => void;
error: (...args: any[]) => void;
};
type EnableFnOptions =
| {
direction: 'down';
result:
| OperationResultEnvelope>
| TRPCClientError;
}
| (Operation & {
direction: 'up';
});
type EnabledFn = (
opts: EnableFnOptions,
) => boolean;
type LoggerLinkFnOptions = Operation &
(
| {
/**
* Request result
*/
direction: 'down';
result:
| OperationResultEnvelope>
| TRPCClientError;
elapsedMs: number;
}
| {
/**
* Request was just initialized
*/
direction: 'up';
}
);
type LoggerLinkFn = (
opts: LoggerLinkFnOptions,
) => void;
type ColorMode = 'ansi' | 'css' | 'none';
export interface LoggerLinkOptions {
logger?: LoggerLinkFn;
enabled?: EnabledFn;
/**
* Used in the built-in defaultLogger
*/
console?: ConsoleEsque;
/**
* Color mode
* @default typeof window === 'undefined' ? 'ansi' : 'css'
*/
colorMode?: ColorMode;
/**
* Include context in the log - defaults to false unless `colorMode` is 'css'
*/
withContext?: boolean;
}
function isFormData(value: unknown): value is FormData {
if (typeof FormData === 'undefined') {
// FormData is not supported
return false;
}
return value instanceof FormData;
}
const palettes = {
css: {
query: ['72e3ff', '3fb0d8'],
mutation: ['c5a3fc', '904dfc'],
subscription: ['ff49e1', 'd83fbe'],
},
ansi: {
regular: {
// Cyan background, black and white text respectively
query: ['\x1b[30;46m', '\x1b[97;46m'],
// Magenta background, black and white text respectively
mutation: ['\x1b[30;45m', '\x1b[97;45m'],
// Green background, black and white text respectively
subscription: ['\x1b[30;42m', '\x1b[97;42m'],
},
bold: {
query: ['\x1b[1;30;46m', '\x1b[1;97;46m'],
mutation: ['\x1b[1;30;45m', '\x1b[1;97;45m'],
subscription: ['\x1b[1;30;42m', '\x1b[1;97;42m'],
},
},
} as const;
function constructPartsAndArgs(
opts: LoggerLinkFnOptions & {
colorMode: ColorMode;
withContext?: boolean;
},
) {
const { direction, type, withContext, path, id, input } = opts;
const parts: string[] = [];
const args: any[] = [];
if (opts.colorMode === 'none') {
parts.push(direction === 'up' ? '>>' : '<<', type, `#${id}`, path);
} else if (opts.colorMode === 'ansi') {
const [lightRegular, darkRegular] = palettes.ansi.regular[type];
const [lightBold, darkBold] = palettes.ansi.bold[type];
const reset = '\x1b[0m';
parts.push(
direction === 'up' ? lightRegular : darkRegular,
direction === 'up' ? '>>' : '<<',
type,
direction === 'up' ? lightBold : darkBold,
`#${id}`,
path,
reset,
);
} else {
// css color mode
const [light, dark] = palettes.css[type];
const css = `
background-color: #${direction === 'up' ? light : dark};
color: ${direction === 'up' ? 'black' : 'white'};
padding: 2px;
`;
parts.push(
'%c',
direction === 'up' ? '>>' : '<<',
type,
`#${id}`,
`%c${path}%c`,
'%O',
);
args.push(
css,
`${css}; font-weight: bold;`,
`${css}; font-weight: normal;`,
);
}
if (direction === 'up') {
args.push(withContext ? { input, context: opts.context } : { input });
} else {
args.push({
input,
result: opts.result,
elapsedMs: opts.elapsedMs,
...(withContext && { context: opts.context }),
});
}
return { parts, args };
}
// maybe this should be moved to it's own package
const defaultLogger =
({
c = console,
colorMode = 'css',
withContext,
}: {
c?: ConsoleEsque;
colorMode?: ColorMode;
withContext?: boolean;
}): LoggerLinkFn =>
(props) => {
const rawInput = props.input;
const input = isFormData(rawInput)
? Object.fromEntries(rawInput)
: rawInput;
const { parts, args } = constructPartsAndArgs({
...props,
colorMode,
input,
withContext,
});
const fn: 'error' | 'log' =
props.direction === 'down' &&
props.result &&
(props.result instanceof Error ||
('error' in props.result.result && props.result.result.error))
? 'error'
: 'log';
c[fn].apply(null, [parts.join(' ')].concat(args));
};
/**
* @see https://trpc.io/docs/v11/client/links/loggerLink
*/
export function loggerLink(
opts: LoggerLinkOptions = {},
): TRPCLink {
const { enabled = () => true } = opts;
const colorMode =
opts.colorMode ?? (typeof window === 'undefined' ? 'ansi' : 'css');
const withContext = opts.withContext ?? colorMode === 'css';
const {
logger = defaultLogger({ c: opts.console, colorMode, withContext }),
} = opts;
return () => {
return ({ op, next }) => {
return observable((observer) => {
// ->
if (enabled({ ...op, direction: 'up' })) {
logger({
...op,
direction: 'up',
});
}
const requestStartTime = Date.now();
function logResult(
result:
| OperationResultEnvelope>
| TRPCClientError,
) {
const elapsedMs = Date.now() - requestStartTime;
if (enabled({ ...op, direction: 'down', result })) {
logger({
...op,
direction: 'down',
elapsedMs,
result,
});
}
}
return next(op)
.pipe(
tap({
next(result) {
logResult(result);
},
error(result) {
logResult(result);
},
}),
)
.subscribe(observer);
});
};
};
}