@typokit/core 0.1.4

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.
Files changed (51) hide show
  1. package/dist/adapters/database.d.ts +28 -0
  2. package/dist/adapters/database.d.ts.map +1 -0
  3. package/dist/adapters/database.js +2 -0
  4. package/dist/adapters/database.js.map +1 -0
  5. package/dist/adapters/server.d.ts +35 -0
  6. package/dist/adapters/server.d.ts.map +1 -0
  7. package/dist/adapters/server.js +2 -0
  8. package/dist/adapters/server.js.map +1 -0
  9. package/dist/app.d.ts +36 -0
  10. package/dist/app.d.ts.map +1 -0
  11. package/dist/app.js +55 -0
  12. package/dist/app.js.map +1 -0
  13. package/dist/error-middleware.d.ts +17 -0
  14. package/dist/error-middleware.d.ts.map +1 -0
  15. package/dist/error-middleware.js +138 -0
  16. package/dist/error-middleware.js.map +1 -0
  17. package/dist/handler.d.ts +41 -0
  18. package/dist/handler.d.ts.map +1 -0
  19. package/dist/handler.js +22 -0
  20. package/dist/handler.js.map +1 -0
  21. package/dist/hooks.d.ts +48 -0
  22. package/dist/hooks.d.ts.map +1 -0
  23. package/dist/hooks.js +64 -0
  24. package/dist/hooks.js.map +1 -0
  25. package/dist/index.d.ts +9 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +6 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/middleware.d.ts +35 -0
  30. package/dist/middleware.d.ts.map +1 -0
  31. package/dist/middleware.js +54 -0
  32. package/dist/middleware.js.map +1 -0
  33. package/dist/plugin.d.ts +74 -0
  34. package/dist/plugin.d.ts.map +1 -0
  35. package/dist/plugin.js +3 -0
  36. package/dist/plugin.js.map +1 -0
  37. package/package.json +29 -0
  38. package/src/adapters/database.ts +37 -0
  39. package/src/adapters/server.ts +55 -0
  40. package/src/app.test.ts +438 -0
  41. package/src/app.ts +118 -0
  42. package/src/error-middleware.test.ts +263 -0
  43. package/src/error-middleware.ts +186 -0
  44. package/src/handler.test.ts +346 -0
  45. package/src/handler.ts +64 -0
  46. package/src/hooks.test.ts +419 -0
  47. package/src/hooks.ts +114 -0
  48. package/src/index.ts +51 -0
  49. package/src/middleware.test.ts +253 -0
  50. package/src/middleware.ts +100 -0
  51. package/src/plugin.ts +108 -0
@@ -0,0 +1,263 @@
1
+ import { describe, it, expect } from "@rstest/core";
2
+ import { createErrorMiddleware } from "./error-middleware.js";
3
+ import { AppError, ValidationError, NotFoundError } from "@typokit/errors";
4
+ import { createRequestContext } from "./middleware.js";
5
+
6
+ import type {
7
+ TypoKitRequest,
8
+ TypoKitResponse,
9
+ ErrorResponse,
10
+ } from "@typokit/types";
11
+
12
+ // ─── Helpers ─────────────────────────────────────────────────
13
+
14
+ function makeReq(): TypoKitRequest {
15
+ return {
16
+ method: "GET",
17
+ path: "/test",
18
+ headers: {},
19
+ body: undefined,
20
+ query: {},
21
+ params: {},
22
+ };
23
+ }
24
+
25
+ function makeCtx() {
26
+ return createRequestContext({ requestId: "trace-abc-123" });
27
+ }
28
+
29
+ function nextReturning(res: TypoKitResponse): () => Promise<TypoKitResponse> {
30
+ return async () => res;
31
+ }
32
+
33
+ function nextThrowing(error: unknown): () => Promise<TypoKitResponse> {
34
+ return async () => {
35
+ throw error;
36
+ };
37
+ }
38
+
39
+ // ─── AppError Serialization ──────────────────────────────────
40
+
41
+ describe("createErrorMiddleware — AppError", () => {
42
+ it("serializes AppError with correct status, code, message, and traceId", async () => {
43
+ const mw = createErrorMiddleware();
44
+ const error = new AppError("SOME_ERROR", 422, "Something went wrong", {
45
+ field: "name",
46
+ });
47
+ const res = await mw(makeReq(), makeCtx(), nextThrowing(error));
48
+ const body = res.body as ErrorResponse;
49
+
50
+ expect(res.status).toBe(422);
51
+ expect(res.headers["content-type"]).toBe("application/json");
52
+ expect(body.error.code).toBe("SOME_ERROR");
53
+ expect(body.error.message).toBe("Something went wrong");
54
+ expect(body.error.details).toEqual({ field: "name" });
55
+ expect(body.error.traceId).toBe("trace-abc-123");
56
+ });
57
+
58
+ it("serializes NotFoundError as 404", async () => {
59
+ const mw = createErrorMiddleware();
60
+ const error = new NotFoundError("NOT_FOUND", "Resource not found");
61
+ const res = await mw(makeReq(), makeCtx(), nextThrowing(error));
62
+ const body = res.body as ErrorResponse;
63
+
64
+ expect(res.status).toBe(404);
65
+ expect(body.error.code).toBe("NOT_FOUND");
66
+ expect(body.error.traceId).toBe("trace-abc-123");
67
+ });
68
+
69
+ it("serializes ValidationError as 400", async () => {
70
+ const mw = createErrorMiddleware();
71
+ const error = new ValidationError("INVALID_INPUT", "Invalid input", {
72
+ fields: ["name"],
73
+ });
74
+ const res = await mw(makeReq(), makeCtx(), nextThrowing(error));
75
+ const body = res.body as ErrorResponse;
76
+
77
+ expect(res.status).toBe(400);
78
+ expect(body.error.code).toBe("INVALID_INPUT");
79
+ expect(body.error.details).toEqual({ fields: ["name"] });
80
+ });
81
+
82
+ it("passes through successful responses without modification", async () => {
83
+ const mw = createErrorMiddleware();
84
+ const okResponse: TypoKitResponse = {
85
+ status: 200,
86
+ headers: { "content-type": "application/json" },
87
+ body: { data: "ok" },
88
+ };
89
+ const res = await mw(makeReq(), makeCtx(), nextReturning(okResponse));
90
+
91
+ expect(res.status).toBe(200);
92
+ expect(res.body).toEqual({ data: "ok" });
93
+ });
94
+ });
95
+
96
+ // ─── Unknown Error Redaction (Production) ────────────────────
97
+
98
+ describe("createErrorMiddleware — unknown errors (production)", () => {
99
+ it("returns 500 with generic message for unknown errors", async () => {
100
+ const mw = createErrorMiddleware({ isDev: false });
101
+ const res = await mw(
102
+ makeReq(),
103
+ makeCtx(),
104
+ nextThrowing(new Error("DB connection failed")),
105
+ );
106
+ const body = res.body as ErrorResponse;
107
+
108
+ expect(res.status).toBe(500);
109
+ expect(body.error.code).toBe("INTERNAL_SERVER_ERROR");
110
+ expect(body.error.message).toBe("Internal Server Error");
111
+ expect(body.error.traceId).toBe("trace-abc-123");
112
+ });
113
+
114
+ it("does not leak error details in production", async () => {
115
+ const mw = createErrorMiddleware({ isDev: false });
116
+ const res = await mw(
117
+ makeReq(),
118
+ makeCtx(),
119
+ nextThrowing(new Error("secret DB password: p@ss")),
120
+ );
121
+ const body = res.body as ErrorResponse;
122
+
123
+ expect(body.error.message).toBe("Internal Server Error");
124
+ expect(body.error.details).toBeUndefined();
125
+ });
126
+
127
+ it("logs full error details in production", async () => {
128
+ const logged: Array<{ message: string; data?: Record<string, unknown> }> =
129
+ [];
130
+ const ctx = createRequestContext({
131
+ requestId: "trace-log-test",
132
+ log: {
133
+ trace: () => {},
134
+ debug: () => {},
135
+ info: () => {},
136
+ warn: () => {},
137
+ error: (message, data) => {
138
+ logged.push({ message, data });
139
+ },
140
+ fatal: () => {},
141
+ },
142
+ });
143
+ const mw = createErrorMiddleware({ isDev: false });
144
+ const thrown = new Error("secret failure");
145
+ await mw(makeReq(), ctx, nextThrowing(thrown));
146
+
147
+ expect(logged.length).toBe(1);
148
+ expect(logged[0].message).toBe("Unhandled error");
149
+ expect(logged[0].data?.message).toBe("secret failure");
150
+ expect(logged[0].data?.traceId).toBe("trace-log-test");
151
+ expect(typeof logged[0].data?.stack).toBe("string");
152
+ });
153
+
154
+ it("handles non-Error thrown values in production", async () => {
155
+ const mw = createErrorMiddleware({ isDev: false });
156
+ const res = await mw(makeReq(), makeCtx(), nextThrowing("string error"));
157
+ const body = res.body as ErrorResponse;
158
+
159
+ expect(res.status).toBe(500);
160
+ expect(body.error.message).toBe("Internal Server Error");
161
+ });
162
+ });
163
+
164
+ // ─── Development Mode ────────────────────────────────────────
165
+
166
+ describe("createErrorMiddleware — development mode", () => {
167
+ it("includes stack trace for unknown errors in dev mode", async () => {
168
+ const mw = createErrorMiddleware({ isDev: true });
169
+ const thrown = new Error("dev error details");
170
+ const res = await mw(makeReq(), makeCtx(), nextThrowing(thrown));
171
+ const body = res.body as ErrorResponse;
172
+
173
+ expect(res.status).toBe(500);
174
+ expect(body.error.code).toBe("INTERNAL_SERVER_ERROR");
175
+ expect(body.error.message).toBe("dev error details");
176
+ expect(body.error.details?.stack).toBeDefined();
177
+ expect(typeof body.error.details?.stack).toBe("string");
178
+ expect(body.error.details?.name).toBe("Error");
179
+ expect(body.error.traceId).toBe("trace-abc-123");
180
+ });
181
+
182
+ it("includes source location (stack) in dev mode", async () => {
183
+ const mw = createErrorMiddleware({ isDev: true });
184
+ const thrown = new TypeError("null is not a function");
185
+ const res = await mw(makeReq(), makeCtx(), nextThrowing(thrown));
186
+ const body = res.body as ErrorResponse;
187
+
188
+ expect(body.error.details?.name).toBe("TypeError");
189
+ expect((body.error.details?.stack as string).includes("TypeError")).toBe(
190
+ true,
191
+ );
192
+ });
193
+
194
+ it("handles non-Error thrown values in dev mode", async () => {
195
+ const mw = createErrorMiddleware({ isDev: true });
196
+ const res = await mw(makeReq(), makeCtx(), nextThrowing(42));
197
+ const body = res.body as ErrorResponse;
198
+
199
+ expect(res.status).toBe(500);
200
+ expect(body.error.message).toBe("42");
201
+ });
202
+ });
203
+
204
+ // ─── Typia Validation Errors ─────────────────────────────────
205
+
206
+ describe("createErrorMiddleware — Typia validation errors", () => {
207
+ it("handles TypeGuardError with path, expected, value", async () => {
208
+ const mw = createErrorMiddleware();
209
+ const error = new Error("invalid type: expected string, got number");
210
+ error.name = "TypeGuardError";
211
+ Object.assign(error, {
212
+ path: "input.name",
213
+ expected: "string",
214
+ value: 123,
215
+ });
216
+
217
+ const res = await mw(makeReq(), makeCtx(), nextThrowing(error));
218
+ const body = res.body as ErrorResponse;
219
+
220
+ expect(res.status).toBe(400);
221
+ expect(body.error.code).toBe("VALIDATION_ERROR");
222
+ expect(body.error.traceId).toBe("trace-abc-123");
223
+ expect(body.error.details?.fields).toEqual([
224
+ { path: "input.name", expected: "string", value: 123 },
225
+ ]);
226
+ });
227
+
228
+ it("handles Typia errors with errors array", async () => {
229
+ const mw = createErrorMiddleware();
230
+ const error = new Error("Validation failed");
231
+ error.name = "TypeGuardError";
232
+ Object.assign(error, {
233
+ errors: [
234
+ { path: "input.name", expected: "string", value: 42 },
235
+ { path: "input.age", expected: "number", value: "old" },
236
+ ],
237
+ });
238
+
239
+ const res = await mw(makeReq(), makeCtx(), nextThrowing(error));
240
+ const body = res.body as ErrorResponse;
241
+
242
+ expect(res.status).toBe(400);
243
+ expect(body.error.code).toBe("VALIDATION_ERROR");
244
+ const fields = body.error.details?.fields as Array<Record<string, unknown>>;
245
+ expect(fields.length).toBe(2);
246
+ expect(fields[0].path).toBe("input.name");
247
+ expect(fields[1].path).toBe("input.age");
248
+ });
249
+
250
+ it("uses error.message for Typia validation errors", async () => {
251
+ const mw = createErrorMiddleware();
252
+ const error = new Error("Expected string but got number at input.name");
253
+ error.name = "TypeGuardError";
254
+ Object.assign(error, { path: "input.name", expected: "string", value: 99 });
255
+
256
+ const res = await mw(makeReq(), makeCtx(), nextThrowing(error));
257
+ const body = res.body as ErrorResponse;
258
+
259
+ expect(body.error.message).toBe(
260
+ "Expected string but got number at input.name",
261
+ );
262
+ });
263
+ });
@@ -0,0 +1,186 @@
1
+ // @typokit/core — Error Middleware
2
+
3
+ import type {
4
+ TypoKitRequest,
5
+ RequestContext,
6
+ TypoKitResponse,
7
+ ErrorResponse,
8
+ Logger,
9
+ } from "@typokit/types";
10
+ import { AppError } from "@typokit/errors";
11
+
12
+ // ─── Options ─────────────────────────────────────────────────
13
+
14
+ /** Options for configuring the error middleware */
15
+ export interface ErrorMiddlewareOptions {
16
+ /** Override dev mode detection (defaults to NODE_ENV === "development") */
17
+ isDev?: boolean;
18
+ }
19
+
20
+ // ─── Typia Validation Error Detection ────────────────────────
21
+
22
+ /** Shape of a single Typia validation failure */
23
+ interface TypiaValidationFailure {
24
+ path: string;
25
+ expected: string;
26
+ value: unknown;
27
+ }
28
+
29
+ /** Duck-type check for Typia TypeGuardError (thrown by typia.assert) */
30
+ function isTypiaTypeGuardError(
31
+ error: unknown,
32
+ ): error is Error & { path?: string; expected?: string; value?: unknown } {
33
+ if (!(error instanceof Error)) return false;
34
+ return error.name === "TypeGuardError";
35
+ }
36
+
37
+ /** Duck-type check for objects with Typia-style errors array */
38
+ function hasTypiaErrors(
39
+ error: unknown,
40
+ ): error is Error & { errors: TypiaValidationFailure[] } {
41
+ if (!(error instanceof Error)) return false;
42
+ const candidate = error as unknown as Record<string, unknown>;
43
+ return (
44
+ Array.isArray(candidate.errors) &&
45
+ candidate.errors.length > 0 &&
46
+ typeof (candidate.errors[0] as Record<string, unknown>).path === "string"
47
+ );
48
+ }
49
+
50
+ /** Extract field-level details from a Typia validation error */
51
+ function extractTypiaDetails(error: Error): Record<string, unknown> {
52
+ if (hasTypiaErrors(error)) {
53
+ return {
54
+ fields: error.errors.map((e) => ({
55
+ path: e.path,
56
+ expected: e.expected,
57
+ value: e.value,
58
+ })),
59
+ };
60
+ }
61
+
62
+ const guard = error as { path?: string; expected?: string; value?: unknown };
63
+ if (guard.path !== undefined) {
64
+ return {
65
+ fields: [
66
+ {
67
+ path: guard.path,
68
+ expected: guard.expected,
69
+ value: guard.value,
70
+ },
71
+ ],
72
+ };
73
+ }
74
+
75
+ return {};
76
+ }
77
+
78
+ // ─── Error Middleware Factory ─────────────────────────────────
79
+
80
+ /**
81
+ * Built-in error middleware — catches all thrown errors and serializes
82
+ * them into the ErrorResponse schema.
83
+ *
84
+ * - AppError: serialized with correct status, code, message, details, traceId
85
+ * - Typia validation errors: 400 with field-level failure details
86
+ * - Unknown errors (prod): 500 generic message, full details logged
87
+ * - Unknown errors (dev): 500 with stack trace and message exposed
88
+ */
89
+ export function createErrorMiddleware(
90
+ options?: ErrorMiddlewareOptions,
91
+ ): (
92
+ req: TypoKitRequest,
93
+ ctx: RequestContext,
94
+ next: () => Promise<TypoKitResponse>,
95
+ ) => Promise<TypoKitResponse> {
96
+ const isDev =
97
+ options?.isDev ??
98
+ (typeof globalThis !== "undefined" &&
99
+ (globalThis as unknown as { process?: { env?: Record<string, string> } })
100
+ .process?.env?.NODE_ENV === "development");
101
+
102
+ return async (_req, ctx, next) => {
103
+ try {
104
+ return await next();
105
+ } catch (error: unknown) {
106
+ const traceId = ctx.requestId;
107
+
108
+ // ── AppError instances ──
109
+ if (error instanceof AppError) {
110
+ const json: ErrorResponse = error.toJSON();
111
+ json.error.traceId = traceId;
112
+ return {
113
+ status: error.status,
114
+ headers: { "content-type": "application/json" },
115
+ body: json,
116
+ };
117
+ }
118
+
119
+ // ── Typia validation errors ──
120
+ if (isTypiaTypeGuardError(error) || hasTypiaErrors(error)) {
121
+ const details = extractTypiaDetails(error);
122
+ const body: ErrorResponse = {
123
+ error: {
124
+ code: "VALIDATION_ERROR",
125
+ message: error.message || "Validation failed",
126
+ details,
127
+ traceId,
128
+ },
129
+ };
130
+ return {
131
+ status: 400,
132
+ headers: { "content-type": "application/json" },
133
+ body,
134
+ };
135
+ }
136
+
137
+ // ── Unknown errors ──
138
+ const err = error instanceof Error ? error : new Error(String(error));
139
+
140
+ if (isDev) {
141
+ // Development: expose stack trace and message
142
+ const body: ErrorResponse = {
143
+ error: {
144
+ code: "INTERNAL_SERVER_ERROR",
145
+ message: err.message,
146
+ details: {
147
+ stack: err.stack,
148
+ name: err.name,
149
+ },
150
+ traceId,
151
+ },
152
+ };
153
+ return {
154
+ status: 500,
155
+ headers: { "content-type": "application/json" },
156
+ body,
157
+ };
158
+ }
159
+
160
+ // Production: log full details, return redacted response
161
+ logUnknownError(ctx.log, err, traceId);
162
+ const body: ErrorResponse = {
163
+ error: {
164
+ code: "INTERNAL_SERVER_ERROR",
165
+ message: "Internal Server Error",
166
+ traceId,
167
+ },
168
+ };
169
+ return {
170
+ status: 500,
171
+ headers: { "content-type": "application/json" },
172
+ body,
173
+ };
174
+ }
175
+ };
176
+ }
177
+
178
+ /** Log full error details in production mode */
179
+ function logUnknownError(log: Logger, err: Error, traceId: string): void {
180
+ log.error("Unhandled error", {
181
+ traceId,
182
+ name: err.name,
183
+ message: err.message,
184
+ stack: err.stack,
185
+ });
186
+ }