@prairielearn/trpc 0.0.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/LICENSE +683 -0
- package/README.md +16 -36
- package/dist/client.d.ts +47 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +29 -0
- package/dist/client.js.map +1 -0
- package/dist/client.test.d.ts +2 -0
- package/dist/client.test.d.ts.map +1 -0
- package/dist/client.test.js +61 -0
- package/dist/client.test.js.map +1 -0
- package/dist/express.d.ts +42 -0
- package/dist/express.d.ts.map +1 -0
- package/dist/express.js +67 -0
- package/dist/express.js.map +1 -0
- package/dist/express.test.d.ts +2 -0
- package/dist/express.test.d.ts.map +1 -0
- package/dist/express.test.js +51 -0
- package/dist/express.test.js.map +1 -0
- package/dist/react.d.ts +26 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +23 -0
- package/dist/react.js.map +1 -0
- package/dist/react.test.d.ts +2 -0
- package/dist/react.test.d.ts.map +1 -0
- package/dist/react.test.js +35 -0
- package/dist/react.test.js.map +1 -0
- package/dist/server.d.ts +46 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +31 -0
- package/dist/server.js.map +1 -0
- package/dist/server.test.d.ts +2 -0
- package/dist/server.test.d.ts.map +1 -0
- package/dist/server.test.js +34 -0
- package/dist/server.test.js.map +1 -0
- package/package.json +43 -8
- package/src/client.test.tsx +84 -0
- package/src/client.ts +54 -0
- package/src/express.test.ts +66 -0
- package/src/express.ts +103 -0
- package/src/react.test.tsx +55 -0
- package/src/react.tsx +53 -0
- package/src/server.test.ts +51 -0
- package/src/server.ts +77 -0
- package/tsconfig.json +6 -0
- package/vitest.config.ts +11 -0
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { type TRPCDefaultErrorShape, TRPCError, type TRPC_ERROR_CODE_KEY } from '@trpc/server';
|
|
2
|
+
/** Metadata serialized for a typed application-level error. */
|
|
3
|
+
export interface AppErrorBase {
|
|
4
|
+
code: string;
|
|
5
|
+
message: string;
|
|
6
|
+
[key: string]: unknown;
|
|
7
|
+
}
|
|
8
|
+
type Values<T> = T[keyof T];
|
|
9
|
+
/** Returns `fail` when `E` is not a member of at least one entry in `Map`. */
|
|
10
|
+
type ErrorInvalidForAny<E, Map> = {
|
|
11
|
+
[K in keyof Map]: E extends Map[K] ? never : 'fail';
|
|
12
|
+
}[keyof Map];
|
|
13
|
+
/** Computes the error variants shared by every procedure in an error map. */
|
|
14
|
+
type SharedProcedureErrors<Map> = Values<Map> extends infer E ? E extends unknown ? ErrorInvalidForAny<E, Map> extends never ? E : never : never : never;
|
|
15
|
+
type ResolveAppErrorForThrow<T> = T extends {
|
|
16
|
+
code: string;
|
|
17
|
+
} ? T : SharedProcedureErrors<T>;
|
|
18
|
+
/** The metadata accepted by {@link throwAppError}. */
|
|
19
|
+
export type AppErrorInput<T> = ResolveAppErrorForThrow<T> & {
|
|
20
|
+
message: string;
|
|
21
|
+
};
|
|
22
|
+
/** The tRPC error shape produced by {@link appErrorFormatter}. */
|
|
23
|
+
export interface AppErrorShape extends TRPCDefaultErrorShape {
|
|
24
|
+
data: TRPCDefaultErrorShape['data'] & {
|
|
25
|
+
appError?: AppErrorBase;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Attaches typed application-error metadata to a tRPC error response.
|
|
30
|
+
*
|
|
31
|
+
* Pass this formatter to each authorization scope's context-bound `initTRPC.create()` call.
|
|
32
|
+
*/
|
|
33
|
+
export declare const appErrorFormatter: ({ shape, error, }: {
|
|
34
|
+
shape: TRPCDefaultErrorShape;
|
|
35
|
+
error: TRPCError;
|
|
36
|
+
}) => AppErrorShape;
|
|
37
|
+
/**
|
|
38
|
+
* Throws a typed application error.
|
|
39
|
+
*
|
|
40
|
+
* A direct procedure error type accepts all of that procedure's variants. A whole procedure error
|
|
41
|
+
* map accepts only variants shared by every procedure; this is intentionally narrower than the
|
|
42
|
+
* client-side whole-map resolution used by `getAppError`.
|
|
43
|
+
*/
|
|
44
|
+
export declare function throwAppError<T>(meta: AppErrorInput<T>, trpcCode?: TRPC_ERROR_CODE_KEY): never;
|
|
45
|
+
export {};
|
|
46
|
+
//# sourceMappingURL=server.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,qBAAqB,EAAE,SAAS,EAAE,KAAK,mBAAmB,EAAE,MAAM,cAAc,CAAC;AAE/F,+DAA+D;AAC/D,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,KAAK,MAAM,CAAC,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC;AAE5B,8EAA8E;AAC9E,KAAK,kBAAkB,CAAC,CAAC,EAAE,GAAG,IAAI;KAC/B,CAAC,IAAI,MAAM,GAAG,GAAG,CAAC,SAAS,GAAG,CAAC,CAAC,CAAC,GAAG,KAAK,GAAG,MAAM;CACpD,CAAC,MAAM,GAAG,CAAC,CAAC;AAEb,6EAA6E;AAC7E,KAAK,qBAAqB,CAAC,GAAG,IAC5B,MAAM,CAAC,GAAG,CAAC,SAAS,MAAM,CAAC,GACvB,CAAC,SAAS,OAAO,GACf,kBAAkB,CAAC,CAAC,EAAE,GAAG,CAAC,SAAS,KAAK,GACtC,CAAC,GACD,KAAK,GACP,KAAK,GACP,KAAK,CAAC;AAEZ,KAAK,uBAAuB,CAAC,CAAC,IAAI,CAAC,SAAS;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG,CAAC,GAAG,qBAAqB,CAAC,CAAC,CAAC,CAAC;AAE5F,sDAAsD;AACtD,MAAM,MAAM,aAAa,CAAC,CAAC,IAAI,uBAAuB,CAAC,CAAC,CAAC,GAAG;IAAE,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAEhF,kEAAkE;AAClE,MAAM,WAAW,aAAc,SAAQ,qBAAqB;IAC1D,IAAI,EAAE,qBAAqB,CAAC,MAAM,CAAC,GAAG;QAAE,QAAQ,CAAC,EAAE,YAAY,CAAA;KAAE,CAAC;CACnE;AAWD;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,sBAG3B;IACD,KAAK,EAAE,qBAAqB,CAAC;IAC7B,KAAK,EAAE,SAAS,CAAC;CAClB,KAAG,aAMF,CAAC;AAEH;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,CAAC,EAC7B,IAAI,EAAE,aAAa,CAAC,CAAC,CAAC,EACtB,QAAQ,GAAE,mBAAmC,GAC5C,KAAK,CAEP","sourcesContent":["import { type TRPCDefaultErrorShape, TRPCError, type TRPC_ERROR_CODE_KEY } from '@trpc/server';\n\n/** Metadata serialized for a typed application-level error. */\nexport interface AppErrorBase {\n code: string;\n message: string;\n [key: string]: unknown;\n}\n\ntype Values<T> = T[keyof T];\n\n/** Returns `fail` when `E` is not a member of at least one entry in `Map`. */\ntype ErrorInvalidForAny<E, Map> = {\n [K in keyof Map]: E extends Map[K] ? never : 'fail';\n}[keyof Map];\n\n/** Computes the error variants shared by every procedure in an error map. */\ntype SharedProcedureErrors<Map> =\n Values<Map> extends infer E\n ? E extends unknown\n ? ErrorInvalidForAny<E, Map> extends never\n ? E\n : never\n : never\n : never;\n\ntype ResolveAppErrorForThrow<T> = T extends { code: string } ? T : SharedProcedureErrors<T>;\n\n/** The metadata accepted by {@link throwAppError}. */\nexport type AppErrorInput<T> = ResolveAppErrorForThrow<T> & { message: string };\n\n/** The tRPC error shape produced by {@link appErrorFormatter}. */\nexport interface AppErrorShape extends TRPCDefaultErrorShape {\n data: TRPCDefaultErrorShape['data'] & { appError?: AppErrorBase };\n}\n\nclass AppError extends TRPCError {\n constructor(\n public readonly meta: AppErrorBase,\n trpcCode: TRPC_ERROR_CODE_KEY = 'BAD_REQUEST',\n ) {\n super({ code: trpcCode, message: meta.message });\n }\n}\n\n/**\n * Attaches typed application-error metadata to a tRPC error response.\n *\n * Pass this formatter to each authorization scope's context-bound `initTRPC.create()` call.\n */\nexport const appErrorFormatter = ({\n shape,\n error,\n}: {\n shape: TRPCDefaultErrorShape;\n error: TRPCError;\n}): AppErrorShape => ({\n ...shape,\n data: {\n ...shape.data,\n ...(error instanceof AppError ? { appError: error.meta } : {}),\n },\n});\n\n/**\n * Throws a typed application error.\n *\n * A direct procedure error type accepts all of that procedure's variants. A whole procedure error\n * map accepts only variants shared by every procedure; this is intentionally narrower than the\n * client-side whole-map resolution used by `getAppError`.\n */\nexport function throwAppError<T>(\n meta: AppErrorInput<T>,\n trpcCode: TRPC_ERROR_CODE_KEY = 'BAD_REQUEST',\n): never {\n throw new AppError(meta as unknown as AppErrorBase, trpcCode);\n}\n"]}
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { TRPCError } from '@trpc/server';
|
|
2
|
+
class AppError extends TRPCError {
|
|
3
|
+
meta;
|
|
4
|
+
constructor(meta, trpcCode = 'BAD_REQUEST') {
|
|
5
|
+
super({ code: trpcCode, message: meta.message });
|
|
6
|
+
this.meta = meta;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Attaches typed application-error metadata to a tRPC error response.
|
|
11
|
+
*
|
|
12
|
+
* Pass this formatter to each authorization scope's context-bound `initTRPC.create()` call.
|
|
13
|
+
*/
|
|
14
|
+
export const appErrorFormatter = ({ shape, error, }) => ({
|
|
15
|
+
...shape,
|
|
16
|
+
data: {
|
|
17
|
+
...shape.data,
|
|
18
|
+
...(error instanceof AppError ? { appError: error.meta } : {}),
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
/**
|
|
22
|
+
* Throws a typed application error.
|
|
23
|
+
*
|
|
24
|
+
* A direct procedure error type accepts all of that procedure's variants. A whole procedure error
|
|
25
|
+
* map accepts only variants shared by every procedure; this is intentionally narrower than the
|
|
26
|
+
* client-side whole-map resolution used by `getAppError`.
|
|
27
|
+
*/
|
|
28
|
+
export function throwAppError(meta, trpcCode = 'BAD_REQUEST') {
|
|
29
|
+
throw new AppError(meta, trpcCode);
|
|
30
|
+
}
|
|
31
|
+
//# sourceMappingURL=server.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA,OAAO,EAA8B,SAAS,EAA4B,MAAM,cAAc,CAAC;AAoC/F,MAAM,QAAS,SAAQ,SAAS;IAEZ,IAAI;IADtB,YACkB,IAAkB,EAClC,QAAQ,GAAwB,aAAa;QAE7C,KAAK,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,EAAE,IAAI,CAAC,OAAO,EAAE,CAAC,CAAC;oBAHjC,IAAI;IAItB,CAAC;CACF;AAED;;;;GAIG;AACH,MAAM,CAAC,MAAM,iBAAiB,GAAG,CAAC,EAChC,KAAK,EACL,KAAK,GAIN,EAAiB,EAAE,CAAC,CAAC;IACpB,GAAG,KAAK;IACR,IAAI,EAAE;QACJ,GAAG,KAAK,CAAC,IAAI;QACb,GAAG,CAAC,KAAK,YAAY,QAAQ,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KAC/D;CACF,CAAC,CAAC;AAEH;;;;;;GAMG;AACH,MAAM,UAAU,aAAa,CAC3B,IAAsB,EACtB,QAAQ,GAAwB,aAAa;IAE7C,MAAM,IAAI,QAAQ,CAAC,IAA+B,EAAE,QAAQ,CAAC,CAAC;AAChE,CAAC","sourcesContent":["import { type TRPCDefaultErrorShape, TRPCError, type TRPC_ERROR_CODE_KEY } from '@trpc/server';\n\n/** Metadata serialized for a typed application-level error. */\nexport interface AppErrorBase {\n code: string;\n message: string;\n [key: string]: unknown;\n}\n\ntype Values<T> = T[keyof T];\n\n/** Returns `fail` when `E` is not a member of at least one entry in `Map`. */\ntype ErrorInvalidForAny<E, Map> = {\n [K in keyof Map]: E extends Map[K] ? never : 'fail';\n}[keyof Map];\n\n/** Computes the error variants shared by every procedure in an error map. */\ntype SharedProcedureErrors<Map> =\n Values<Map> extends infer E\n ? E extends unknown\n ? ErrorInvalidForAny<E, Map> extends never\n ? E\n : never\n : never\n : never;\n\ntype ResolveAppErrorForThrow<T> = T extends { code: string } ? T : SharedProcedureErrors<T>;\n\n/** The metadata accepted by {@link throwAppError}. */\nexport type AppErrorInput<T> = ResolveAppErrorForThrow<T> & { message: string };\n\n/** The tRPC error shape produced by {@link appErrorFormatter}. */\nexport interface AppErrorShape extends TRPCDefaultErrorShape {\n data: TRPCDefaultErrorShape['data'] & { appError?: AppErrorBase };\n}\n\nclass AppError extends TRPCError {\n constructor(\n public readonly meta: AppErrorBase,\n trpcCode: TRPC_ERROR_CODE_KEY = 'BAD_REQUEST',\n ) {\n super({ code: trpcCode, message: meta.message });\n }\n}\n\n/**\n * Attaches typed application-error metadata to a tRPC error response.\n *\n * Pass this formatter to each authorization scope's context-bound `initTRPC.create()` call.\n */\nexport const appErrorFormatter = ({\n shape,\n error,\n}: {\n shape: TRPCDefaultErrorShape;\n error: TRPCError;\n}): AppErrorShape => ({\n ...shape,\n data: {\n ...shape.data,\n ...(error instanceof AppError ? { appError: error.meta } : {}),\n },\n});\n\n/**\n * Throws a typed application error.\n *\n * A direct procedure error type accepts all of that procedure's variants. A whole procedure error\n * map accepts only variants shared by every procedure; this is intentionally narrower than the\n * client-side whole-map resolution used by `getAppError`.\n */\nexport function throwAppError<T>(\n meta: AppErrorInput<T>,\n trpcCode: TRPC_ERROR_CODE_KEY = 'BAD_REQUEST',\n): never {\n throw new AppError(meta as unknown as AppErrorBase, trpcCode);\n}\n"]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server.test.d.ts","sourceRoot":"","sources":["../src/server.test.ts"],"names":[],"mappings":"","sourcesContent":["import { TRPCError } from '@trpc/server';\nimport { TRPC_ERROR_CODES_BY_KEY } from '@trpc/server/rpc';\nimport { assert, describe, expectTypeOf, it } from 'vitest';\n\nimport type { AppError } from './client.js';\nimport { appErrorFormatter, throwAppError } from './server.js';\n\ninterface WholeErrorMap {\n First: { code: 'FIRST_ONLY'; first: string } | { code: 'SHARED'; shared: string };\n Second: { code: 'SECOND_ONLY'; second: string } | { code: 'SHARED'; shared: string };\n}\n\ndescribe('typed application errors', () => {\n it('serializes typed metadata through the error formatter', () => {\n const error = assert.throws(() => {\n throwAppError<WholeErrorMap['First']>(\n {\n code: 'FIRST_ONLY',\n message: 'Expected failure',\n first: 'detail',\n },\n 'CONFLICT',\n );\n });\n assert.instanceOf(error, TRPCError);\n\n const result = appErrorFormatter({\n error,\n shape: {\n message: error.message,\n code: TRPC_ERROR_CODES_BY_KEY.CONFLICT,\n data: { code: 'CONFLICT', httpStatus: 409 },\n },\n });\n\n assert.deepEqual(result.data.appError, {\n code: 'FIRST_ONLY',\n message: 'Expected failure',\n first: 'detail',\n });\n });\n\n it('keeps whole-map server errors narrower than whole-map client errors', () => {\n type WholeMapThrowInput = Parameters<typeof throwAppError<WholeErrorMap>>[0];\n\n expectTypeOf<WholeMapThrowInput['code']>().toEqualTypeOf<'SHARED'>();\n expectTypeOf<AppError<WholeErrorMap>['code']>().toEqualTypeOf<\n 'FIRST_ONLY' | 'SECOND_ONLY' | 'SHARED' | 'UNKNOWN'\n >();\n });\n});\n"]}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { TRPCError } from '@trpc/server';
|
|
2
|
+
import { TRPC_ERROR_CODES_BY_KEY } from '@trpc/server/rpc';
|
|
3
|
+
import { assert, describe, expectTypeOf, it } from 'vitest';
|
|
4
|
+
import { appErrorFormatter, throwAppError } from './server.js';
|
|
5
|
+
describe('typed application errors', () => {
|
|
6
|
+
it('serializes typed metadata through the error formatter', () => {
|
|
7
|
+
const error = assert.throws(() => {
|
|
8
|
+
throwAppError({
|
|
9
|
+
code: 'FIRST_ONLY',
|
|
10
|
+
message: 'Expected failure',
|
|
11
|
+
first: 'detail',
|
|
12
|
+
}, 'CONFLICT');
|
|
13
|
+
});
|
|
14
|
+
assert.instanceOf(error, TRPCError);
|
|
15
|
+
const result = appErrorFormatter({
|
|
16
|
+
error,
|
|
17
|
+
shape: {
|
|
18
|
+
message: error.message,
|
|
19
|
+
code: TRPC_ERROR_CODES_BY_KEY.CONFLICT,
|
|
20
|
+
data: { code: 'CONFLICT', httpStatus: 409 },
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
assert.deepEqual(result.data.appError, {
|
|
24
|
+
code: 'FIRST_ONLY',
|
|
25
|
+
message: 'Expected failure',
|
|
26
|
+
first: 'detail',
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
it('keeps whole-map server errors narrower than whole-map client errors', () => {
|
|
30
|
+
expectTypeOf().toEqualTypeOf();
|
|
31
|
+
expectTypeOf().toEqualTypeOf();
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
//# sourceMappingURL=server.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server.test.js","sourceRoot":"","sources":["../src/server.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AACzC,OAAO,EAAE,uBAAuB,EAAE,MAAM,kBAAkB,CAAC;AAC3D,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,YAAY,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAC;AAG5D,OAAO,EAAE,iBAAiB,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAO/D,QAAQ,CAAC,0BAA0B,EAAE,GAAG,EAAE;IACxC,EAAE,CAAC,uDAAuD,EAAE,GAAG,EAAE;QAC/D,MAAM,KAAK,GAAG,MAAM,CAAC,MAAM,CAAC,GAAG,EAAE;YAC/B,aAAa,CACX;gBACE,IAAI,EAAE,YAAY;gBAClB,OAAO,EAAE,kBAAkB;gBAC3B,KAAK,EAAE,QAAQ;aAChB,EACD,UAAU,CACX,CAAC;QACJ,CAAC,CAAC,CAAC;QACH,MAAM,CAAC,UAAU,CAAC,KAAK,EAAE,SAAS,CAAC,CAAC;QAEpC,MAAM,MAAM,GAAG,iBAAiB,CAAC;YAC/B,KAAK;YACL,KAAK,EAAE;gBACL,OAAO,EAAE,KAAK,CAAC,OAAO;gBACtB,IAAI,EAAE,uBAAuB,CAAC,QAAQ;gBACtC,IAAI,EAAE,EAAE,IAAI,EAAE,UAAU,EAAE,UAAU,EAAE,GAAG,EAAE;aAC5C;SACF,CAAC,CAAC;QAEH,MAAM,CAAC,SAAS,CAAC,MAAM,CAAC,IAAI,CAAC,QAAQ,EAAE;YACrC,IAAI,EAAE,YAAY;YAClB,OAAO,EAAE,kBAAkB;YAC3B,KAAK,EAAE,QAAQ;SAChB,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,EAAE,CAAC,qEAAqE,EAAE,GAAG,EAAE;QAG7E,YAAY,EAA8B,CAAC,aAAa,EAAY,CAAC;QACrE,YAAY,EAAmC,CAAC,aAAa,EAE1D,CAAC;IACN,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC","sourcesContent":["import { TRPCError } from '@trpc/server';\nimport { TRPC_ERROR_CODES_BY_KEY } from '@trpc/server/rpc';\nimport { assert, describe, expectTypeOf, it } from 'vitest';\n\nimport type { AppError } from './client.js';\nimport { appErrorFormatter, throwAppError } from './server.js';\n\ninterface WholeErrorMap {\n First: { code: 'FIRST_ONLY'; first: string } | { code: 'SHARED'; shared: string };\n Second: { code: 'SECOND_ONLY'; second: string } | { code: 'SHARED'; shared: string };\n}\n\ndescribe('typed application errors', () => {\n it('serializes typed metadata through the error formatter', () => {\n const error = assert.throws(() => {\n throwAppError<WholeErrorMap['First']>(\n {\n code: 'FIRST_ONLY',\n message: 'Expected failure',\n first: 'detail',\n },\n 'CONFLICT',\n );\n });\n assert.instanceOf(error, TRPCError);\n\n const result = appErrorFormatter({\n error,\n shape: {\n message: error.message,\n code: TRPC_ERROR_CODES_BY_KEY.CONFLICT,\n data: { code: 'CONFLICT', httpStatus: 409 },\n },\n });\n\n assert.deepEqual(result.data.appError, {\n code: 'FIRST_ONLY',\n message: 'Expected failure',\n first: 'detail',\n });\n });\n\n it('keeps whole-map server errors narrower than whole-map client errors', () => {\n type WholeMapThrowInput = Parameters<typeof throwAppError<WholeErrorMap>>[0];\n\n expectTypeOf<WholeMapThrowInput['code']>().toEqualTypeOf<'SHARED'>();\n expectTypeOf<AppError<WholeErrorMap>['code']>().toEqualTypeOf<\n 'FIRST_ONLY' | 'SECOND_ONLY' | 'SHARED' | 'UNKNOWN'\n >();\n });\n});\n"]}
|
package/package.json
CHANGED
|
@@ -1,10 +1,45 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@prairielearn/trpc",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
|
|
10
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/PrairieLearn/PrairieLearn.git",
|
|
8
|
+
"directory": "packages/trpc"
|
|
9
|
+
},
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=24.0.0"
|
|
12
|
+
},
|
|
13
|
+
"exports": {
|
|
14
|
+
"./client": "./dist/client.js",
|
|
15
|
+
"./express": "./dist/express.js",
|
|
16
|
+
"./react": "./dist/react.js",
|
|
17
|
+
"./server": "./dist/server.js"
|
|
18
|
+
},
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"@tanstack/react-query": "^5.101.4",
|
|
21
|
+
"@trpc/client": "^11.18.0",
|
|
22
|
+
"@trpc/server": "^11.18.0",
|
|
23
|
+
"react": "^19.2.8",
|
|
24
|
+
"react-bootstrap": "3.0.0-beta.5"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@types/react": "^19.2.18"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/node": "^24.13.3",
|
|
31
|
+
"@types/react-dom": "^19.2.4",
|
|
32
|
+
"@typescript/native-preview": "beta",
|
|
33
|
+
"@vitest/coverage-v8": "^4.1.10",
|
|
34
|
+
"jsdom": "^29.1.1",
|
|
35
|
+
"react-dom": "^19.2.8",
|
|
36
|
+
"vitest": "^4.1.10",
|
|
37
|
+
"@prairielearn/tsconfig": "^2.1.0"
|
|
38
|
+
},
|
|
39
|
+
"sideEffects": false,
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "tsgo",
|
|
42
|
+
"dev": "tsgo --watch --preserveWatchOutput",
|
|
43
|
+
"test": "vitest run --coverage"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { TRPCClientError } from '@trpc/client';
|
|
2
|
+
import { TRPCError } from '@trpc/server';
|
|
3
|
+
import { TRPC_ERROR_CODES_BY_KEY } from '@trpc/server/rpc';
|
|
4
|
+
import { assert, describe, it } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import { getAppError, renderAppError } from './client.js';
|
|
7
|
+
import { appErrorFormatter, throwAppError } from './server.js';
|
|
8
|
+
|
|
9
|
+
interface ExpectedError {
|
|
10
|
+
code: 'EXPECTED';
|
|
11
|
+
detail: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('getAppError', () => {
|
|
15
|
+
it('extracts typed metadata from a tRPC client error', () => {
|
|
16
|
+
const serverError = assert.throws(() => {
|
|
17
|
+
throwAppError<ExpectedError>({
|
|
18
|
+
message: 'Expected failure',
|
|
19
|
+
code: 'EXPECTED',
|
|
20
|
+
detail: 'more context',
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
assert.instanceOf(serverError, TRPCError);
|
|
24
|
+
|
|
25
|
+
const errorShape = appErrorFormatter({
|
|
26
|
+
error: serverError,
|
|
27
|
+
shape: {
|
|
28
|
+
message: serverError.message,
|
|
29
|
+
code: TRPC_ERROR_CODES_BY_KEY.BAD_REQUEST,
|
|
30
|
+
data: {
|
|
31
|
+
code: 'BAD_REQUEST',
|
|
32
|
+
httpStatus: 400,
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
const error = new TRPCClientError(errorShape.message, {
|
|
37
|
+
result: { error: errorShape },
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
assert.deepEqual(getAppError<ExpectedError>(error), {
|
|
41
|
+
code: 'EXPECTED',
|
|
42
|
+
message: 'Expected failure',
|
|
43
|
+
detail: 'more context',
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('uses UNKNOWN for ordinary errors', () => {
|
|
48
|
+
assert.deepEqual(getAppError<ExpectedError>(new Error('Unexpected failure')), {
|
|
49
|
+
code: 'UNKNOWN',
|
|
50
|
+
message: 'Unexpected failure',
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('renderAppError', () => {
|
|
56
|
+
it('uses an exhaustive renderer for a known code', () => {
|
|
57
|
+
const error = { code: 'EXPECTED' as const, message: 'Expected failure', detail: 'context' };
|
|
58
|
+
|
|
59
|
+
assert.equal(
|
|
60
|
+
renderAppError(error, {
|
|
61
|
+
EXPECTED: ({ message, detail }) => `${message}: ${detail}`,
|
|
62
|
+
}),
|
|
63
|
+
'Expected failure: context',
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('falls back to the message when a stale client receives an unknown runtime code', () => {
|
|
68
|
+
interface KnownError {
|
|
69
|
+
code: 'EXPECTED';
|
|
70
|
+
message: string;
|
|
71
|
+
}
|
|
72
|
+
const staleClientError = {
|
|
73
|
+
code: 'ADDED_AFTER_DEPLOY',
|
|
74
|
+
message: 'The server still explains the failure',
|
|
75
|
+
} as unknown as KnownError;
|
|
76
|
+
|
|
77
|
+
assert.equal(
|
|
78
|
+
renderAppError(staleClientError, {
|
|
79
|
+
EXPECTED: ({ message }) => message,
|
|
80
|
+
}),
|
|
81
|
+
'The server still explains the failure',
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
});
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { TRPCClientError } from '@trpc/client';
|
|
2
|
+
import type { ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Resolves client error types to the variants a component may receive.
|
|
6
|
+
*
|
|
7
|
+
* Unlike the server's whole-map resolution, a whole map becomes the union of its procedure errors
|
|
8
|
+
* because a component may handle failures from several procedures.
|
|
9
|
+
*/
|
|
10
|
+
type ResolveClientAppError<T> = T extends { code: string }
|
|
11
|
+
? T
|
|
12
|
+
: T[keyof T] extends { code: string }
|
|
13
|
+
? T[keyof T]
|
|
14
|
+
: never;
|
|
15
|
+
|
|
16
|
+
/** The resolved, non-null return type of {@link getAppError}. */
|
|
17
|
+
export type AppError<T> =
|
|
18
|
+
| (ResolveClientAppError<T> & { message: string })
|
|
19
|
+
| { code: 'UNKNOWN'; message: string };
|
|
20
|
+
|
|
21
|
+
/** An exhaustive renderer map for every statically known application-error code. */
|
|
22
|
+
export type AppErrorRenderers<E extends { code: string; message: string }> = {
|
|
23
|
+
[K in E['code']]: (error: Extract<E, { code: K }>) => ReactNode;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Extracts typed application metadata from a tRPC client error.
|
|
28
|
+
*
|
|
29
|
+
* Pass a procedure entry for a single operation. Pass a whole error map only when one component
|
|
30
|
+
* handles failures from several procedures; in that case the result is their union.
|
|
31
|
+
*/
|
|
32
|
+
export function getAppError<T>(error: unknown): AppError<T> | null {
|
|
33
|
+
if (error instanceof TRPCClientError) {
|
|
34
|
+
const appError = (error.data as { appError?: ResolveClientAppError<T> } | undefined)?.appError;
|
|
35
|
+
if (appError) return { ...appError, message: error.message };
|
|
36
|
+
return { code: 'UNKNOWN', message: error.message };
|
|
37
|
+
}
|
|
38
|
+
if (error instanceof Error) return { code: 'UNKNOWN', message: error.message };
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Renders an application error with an exhaustive map for statically known variants.
|
|
44
|
+
*
|
|
45
|
+
* If a newer server sends a code unknown to a stale browser bundle, the server message is rendered
|
|
46
|
+
* instead of attempting to call a missing renderer.
|
|
47
|
+
*/
|
|
48
|
+
export function renderAppError<E extends { code: string; message: string }>(
|
|
49
|
+
error: E,
|
|
50
|
+
renderers: AppErrorRenderers<E>,
|
|
51
|
+
): ReactNode {
|
|
52
|
+
const render = renderers[error.code as E['code']] as ((error: E) => ReactNode) | undefined;
|
|
53
|
+
return render ? render(error) : error.message;
|
|
54
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { TRPC_ERROR_CODES_BY_KEY } from '@trpc/server/rpc';
|
|
2
|
+
import { assert, describe, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { formatTrpcErrorResponse, isMultipartRequest, isTrpcRequest } from './express.js';
|
|
5
|
+
|
|
6
|
+
describe('isTrpcRequest', () => {
|
|
7
|
+
it('requires both the tRPC header and mount path segment', () => {
|
|
8
|
+
assert.isTrue(
|
|
9
|
+
isTrpcRequest({
|
|
10
|
+
header: (name) => (name === 'X-TRPC' ? 'true' : undefined),
|
|
11
|
+
originalUrl: '/pl/course/1/trpc/widgets.list?input=%7B%7D',
|
|
12
|
+
}),
|
|
13
|
+
);
|
|
14
|
+
assert.isFalse(
|
|
15
|
+
isTrpcRequest({
|
|
16
|
+
header: (name) => (name === 'X-TRPC' ? 'true' : undefined),
|
|
17
|
+
originalUrl: '/pl/course/1/not-trpc',
|
|
18
|
+
}),
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('isMultipartRequest', () => {
|
|
24
|
+
it('matches the header name and media type case-insensitively', () => {
|
|
25
|
+
assert.isTrue(
|
|
26
|
+
isMultipartRequest({
|
|
27
|
+
headers: { 'Content-Type': 'Multipart/Form-Data; boundary=example' },
|
|
28
|
+
}),
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('rejects non-multipart and missing content types', () => {
|
|
33
|
+
assert.isFalse(isMultipartRequest({ headers: { 'content-type': 'application/json' } }));
|
|
34
|
+
assert.isFalse(isMultipartRequest({ headers: {} }));
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('formatTrpcErrorResponse', () => {
|
|
39
|
+
it('maps an HTTP status to its tRPC error code', () => {
|
|
40
|
+
const response = formatTrpcErrorResponse({ status: 403, message: 'Request failed' });
|
|
41
|
+
|
|
42
|
+
assert.equal(response.error.json.code, TRPC_ERROR_CODES_BY_KEY.FORBIDDEN);
|
|
43
|
+
assert.equal(response.error.json.data.code, 'FORBIDDEN');
|
|
44
|
+
assert.equal(response.error.json.data.httpStatus, 403);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('falls back to INTERNAL_SERVER_ERROR for an unknown status', () => {
|
|
48
|
+
const response = formatTrpcErrorResponse({ status: 418, message: 'Unexpected status' });
|
|
49
|
+
|
|
50
|
+
assert.equal(response.error.json.code, TRPC_ERROR_CODES_BY_KEY.INTERNAL_SERVER_ERROR);
|
|
51
|
+
assert.equal(response.error.json.data.code, 'INTERNAL_SERVER_ERROR');
|
|
52
|
+
assert.equal(response.error.json.data.httpStatus, 418);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('includes a stack only when one is supplied', () => {
|
|
56
|
+
const withStack = formatTrpcErrorResponse({
|
|
57
|
+
status: 500,
|
|
58
|
+
message: 'Failed',
|
|
59
|
+
stack: 'stack trace',
|
|
60
|
+
});
|
|
61
|
+
const withoutStack = formatTrpcErrorResponse({ status: 500, message: 'Failed' });
|
|
62
|
+
|
|
63
|
+
assert.equal(withStack.error.json.data.stack, 'stack trace');
|
|
64
|
+
assert.notProperty(withoutStack.error.json.data, 'stack');
|
|
65
|
+
});
|
|
66
|
+
});
|
package/src/express.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { TRPC_ERROR_CODES_BY_KEY, type TRPC_ERROR_CODE_KEY } from '@trpc/server/rpc';
|
|
2
|
+
|
|
3
|
+
export interface TrpcRequestLike {
|
|
4
|
+
header(name: string): string | undefined;
|
|
5
|
+
originalUrl: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface RequestWithHeaders {
|
|
9
|
+
headers: Record<string, string | string[] | undefined>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Returns true for a tRPC HTTP-link request whose procedure is mounted below a `trpc` path segment.
|
|
14
|
+
*/
|
|
15
|
+
export function isTrpcRequest(req: TrpcRequestLike): boolean {
|
|
16
|
+
// This header is spoofable; the URL path is what Express actually uses to dispatch.
|
|
17
|
+
if (req.header('X-TRPC') !== 'true') return false;
|
|
18
|
+
const pathOnly = req.originalUrl.split('?')[0];
|
|
19
|
+
const segments = pathOnly.split('/');
|
|
20
|
+
return segments.at(-2) === 'trpc';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Returns true when a request has a multipart/form-data content type.
|
|
25
|
+
*
|
|
26
|
+
* Use this to skip JSON and URL-encoded body parsers for file uploads and tRPC `FormData` inputs.
|
|
27
|
+
* Even when those parsers do not consume multipart data, they can assign an empty `req.body` that
|
|
28
|
+
* prevents the tRPC Express adapter from reading the original stream.
|
|
29
|
+
*/
|
|
30
|
+
export function isMultipartRequest(req: RequestWithHeaders): boolean {
|
|
31
|
+
const contentTypeEntry = Object.entries(req.headers).find(
|
|
32
|
+
([headerName]) => headerName.toLowerCase() === 'content-type',
|
|
33
|
+
);
|
|
34
|
+
const contentType = contentTypeEntry?.[1];
|
|
35
|
+
const value = Array.isArray(contentType) ? contentType[0] : contentType;
|
|
36
|
+
return value?.toLowerCase().startsWith('multipart/form-data') ?? false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const HTTP_STATUS_TO_TRPC_ERROR_CODE: Partial<Record<number, TRPC_ERROR_CODE_KEY>> = {
|
|
40
|
+
400: 'BAD_REQUEST',
|
|
41
|
+
401: 'UNAUTHORIZED',
|
|
42
|
+
402: 'PAYMENT_REQUIRED',
|
|
43
|
+
403: 'FORBIDDEN',
|
|
44
|
+
404: 'NOT_FOUND',
|
|
45
|
+
405: 'METHOD_NOT_SUPPORTED',
|
|
46
|
+
408: 'TIMEOUT',
|
|
47
|
+
409: 'CONFLICT',
|
|
48
|
+
412: 'PRECONDITION_FAILED',
|
|
49
|
+
413: 'PAYLOAD_TOO_LARGE',
|
|
50
|
+
415: 'UNSUPPORTED_MEDIA_TYPE',
|
|
51
|
+
422: 'UNPROCESSABLE_CONTENT',
|
|
52
|
+
428: 'PRECONDITION_REQUIRED',
|
|
53
|
+
429: 'TOO_MANY_REQUESTS',
|
|
54
|
+
499: 'CLIENT_CLOSED_REQUEST',
|
|
55
|
+
500: 'INTERNAL_SERVER_ERROR',
|
|
56
|
+
501: 'NOT_IMPLEMENTED',
|
|
57
|
+
502: 'BAD_GATEWAY',
|
|
58
|
+
503: 'SERVICE_UNAVAILABLE',
|
|
59
|
+
504: 'GATEWAY_TIMEOUT',
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export interface TrpcErrorResponse {
|
|
63
|
+
error: {
|
|
64
|
+
json: {
|
|
65
|
+
message: string;
|
|
66
|
+
code: number;
|
|
67
|
+
data: {
|
|
68
|
+
code: TRPC_ERROR_CODE_KEY;
|
|
69
|
+
httpStatus: number;
|
|
70
|
+
stack?: string;
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Formats an error thrown before the tRPC adapter as a SuperJSON-compatible tRPC response.
|
|
78
|
+
*/
|
|
79
|
+
export function formatTrpcErrorResponse({
|
|
80
|
+
status,
|
|
81
|
+
message,
|
|
82
|
+
stack,
|
|
83
|
+
}: {
|
|
84
|
+
status: number;
|
|
85
|
+
message: string;
|
|
86
|
+
stack?: string;
|
|
87
|
+
}): TrpcErrorResponse {
|
|
88
|
+
const code = HTTP_STATUS_TO_TRPC_ERROR_CODE[status] ?? 'INTERNAL_SERVER_ERROR';
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
error: {
|
|
92
|
+
json: {
|
|
93
|
+
message,
|
|
94
|
+
code: TRPC_ERROR_CODES_BY_KEY[code],
|
|
95
|
+
data: {
|
|
96
|
+
code,
|
|
97
|
+
httpStatus: status,
|
|
98
|
+
...(stack === undefined ? {} : { stack }),
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { QueryClient } from '@tanstack/react-query';
|
|
4
|
+
import { act } from 'react';
|
|
5
|
+
import { createRoot } from 'react-dom/client';
|
|
6
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
7
|
+
import { afterEach, assert, describe, it } from 'vitest';
|
|
8
|
+
|
|
9
|
+
import { AppErrorAlert, QueryClientProviderDebug } from './react.js';
|
|
10
|
+
|
|
11
|
+
const testGlobal = globalThis as typeof globalThis & {
|
|
12
|
+
document: { createElement(tagName: string): Parameters<typeof createRoot>[0] };
|
|
13
|
+
IS_REACT_ACT_ENVIRONMENT?: boolean;
|
|
14
|
+
window: Window;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
delete testGlobal.window.__TANSTACK_QUERY_CLIENT__;
|
|
19
|
+
delete testGlobal.IS_REACT_ACT_ENVIRONMENT;
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('AppErrorAlert', () => {
|
|
23
|
+
it('renders an error from a JSX element', () => {
|
|
24
|
+
const error = { code: 'EXPECTED' as const, message: 'Rendered application error' };
|
|
25
|
+
const markup = renderToStaticMarkup(
|
|
26
|
+
<AppErrorAlert
|
|
27
|
+
error={error}
|
|
28
|
+
render={{ EXPECTED: ({ message }) => <span>{message}</span> }}
|
|
29
|
+
/>,
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
assert.include(markup, 'Rendered application error');
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('QueryClientProviderDebug', () => {
|
|
37
|
+
it('exposes the client to browser devtools', async () => {
|
|
38
|
+
testGlobal.IS_REACT_ACT_ENVIRONMENT = true;
|
|
39
|
+
const client = new QueryClient();
|
|
40
|
+
const root = createRoot(testGlobal.document.createElement('div'));
|
|
41
|
+
|
|
42
|
+
assert.isUndefined(testGlobal.window.__TANSTACK_QUERY_CLIENT__);
|
|
43
|
+
await act(async () => {
|
|
44
|
+
root.render(
|
|
45
|
+
<QueryClientProviderDebug client={client}>
|
|
46
|
+
<span>Child</span>
|
|
47
|
+
</QueryClientProviderDebug>,
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
assert.strictEqual(testGlobal.window.__TANSTACK_QUERY_CLIENT__, client);
|
|
51
|
+
|
|
52
|
+
await act(async () => root.unmount());
|
|
53
|
+
client.clear();
|
|
54
|
+
});
|
|
55
|
+
});
|
package/src/react.tsx
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
2
|
+
import { type ReactNode, useEffect } from 'react';
|
|
3
|
+
import { Alert, type AlertProps } from 'react-bootstrap';
|
|
4
|
+
|
|
5
|
+
import { type AppErrorRenderers, renderAppError } from './client.js';
|
|
6
|
+
|
|
7
|
+
declare global {
|
|
8
|
+
interface Window {
|
|
9
|
+
__TANSTACK_QUERY_CLIENT__?: QueryClient;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Renders an application error inside a Bootstrap alert. */
|
|
14
|
+
export function AppErrorAlert<E extends { code: string; message: string }>({
|
|
15
|
+
error,
|
|
16
|
+
onDismiss,
|
|
17
|
+
variant = 'danger',
|
|
18
|
+
className,
|
|
19
|
+
render,
|
|
20
|
+
}: {
|
|
21
|
+
error: E | null | undefined;
|
|
22
|
+
onDismiss?: () => void;
|
|
23
|
+
variant?: AlertProps['variant'];
|
|
24
|
+
className?: string;
|
|
25
|
+
render: AppErrorRenderers<E>;
|
|
26
|
+
}) {
|
|
27
|
+
if (!error) return null;
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<Alert variant={variant} dismissible={!!onDismiss} className={className} onClose={onDismiss}>
|
|
31
|
+
{renderAppError(error, render)}
|
|
32
|
+
</Alert>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Provides a TanStack Query client and exposes it to browser devtools. */
|
|
37
|
+
export function QueryClientProviderDebug({
|
|
38
|
+
client,
|
|
39
|
+
children,
|
|
40
|
+
}: {
|
|
41
|
+
client: QueryClient;
|
|
42
|
+
children: ReactNode;
|
|
43
|
+
}) {
|
|
44
|
+
// Expose the page's query client after mount so the browser extension can discover it.
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
const browserWindow = (globalThis as { window?: Window }).window;
|
|
47
|
+
if (!browserWindow) return;
|
|
48
|
+
|
|
49
|
+
browserWindow.__TANSTACK_QUERY_CLIENT__ = client;
|
|
50
|
+
}, [client]);
|
|
51
|
+
|
|
52
|
+
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
|
53
|
+
}
|