@tatara/http 0.1.0 → 0.1.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tatara/http",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "tatara HTTP: Hono app setup, errors, envelope, OpenAPI docs and route mounting",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -12,7 +12,9 @@
12
12
  }
13
13
  },
14
14
  "files": [
15
- "dist"
15
+ "dist",
16
+ "src",
17
+ "!src/**/*.test.ts"
16
18
  ],
17
19
  "engines": {
18
20
  "node": ">=24"
@@ -22,14 +24,14 @@
22
24
  "@hono/zod-openapi": "^1.6.3",
23
25
  "@hono/node-server": "^2.1.5",
24
26
  "@scalar/hono-api-reference": "^0.12.12",
25
- "@tatara/core": "^0.1.0"
27
+ "@tatara/core": "^0.1.1"
26
28
  },
27
29
  "devDependencies": {
28
30
  "hono": "^4.13.13",
29
31
  "@hono/zod-openapi": "^1.6.3",
30
32
  "@hono/node-server": "^2.1.5",
31
33
  "@scalar/hono-api-reference": "^0.12.12",
32
- "@tatara/core": "^0.1.0",
34
+ "@tatara/core": "^0.1.1",
33
35
  "pino": "^10.4.0",
34
36
  "zod": "^4.6.7"
35
37
  },
package/src/app.ts ADDED
@@ -0,0 +1,164 @@
1
+ import { OpenAPIHono } from "@hono/zod-openapi";
2
+ import { Scalar } from "@scalar/hono-api-reference";
3
+ import { type CoreLogger, runReadinessChecks } from "@tatara/core";
4
+ import type { Context, MiddlewareHandler } from "hono";
5
+ import { bodyLimit } from "hono/body-limit";
6
+ import { cors } from "hono/cors";
7
+ import { createMiddleware } from "hono/factory";
8
+ import { HTTPException } from "hono/http-exception";
9
+ import { secureHeaders } from "hono/secure-headers";
10
+ import { AppError } from "./errors.ts";
11
+ import { validationHook } from "./validation.ts";
12
+
13
+ /** Variables every tatara app has; apps extend them with their own (principal, language, …). */
14
+ export interface HttpEnv {
15
+ Variables: { requestId: string; logger: CoreLogger };
16
+ }
17
+
18
+ type Defaults<E extends HttpEnv> = Partial<Omit<E["Variables"], "requestId" | "logger">>;
19
+
20
+ const REQUEST_ID = /^[\w-]{8,128}$/;
21
+
22
+ /** Sets requestId, a request-scoped logger and default variables; logs one line per request. */
23
+ export function requestContext<E extends HttpEnv>(logger: CoreLogger, defaults: Defaults<E> = {}) {
24
+ return createMiddleware<E>(async (c, next) => {
25
+ const incoming = c.req.header("x-request-id");
26
+ const requestId = incoming && REQUEST_ID.test(incoming) ? incoming : crypto.randomUUID();
27
+ const log = logger.child({ requestId });
28
+ const set = c.set as (key: string, value: unknown) => void;
29
+ set("requestId", requestId);
30
+ set("logger", log);
31
+ for (const [key, value] of Object.entries(defaults)) set(key, value);
32
+ c.header("x-request-id", requestId);
33
+ const start = performance.now();
34
+ await next();
35
+ const ms = Math.round(performance.now() - start);
36
+ const status = c.res.status;
37
+ const entry = { method: c.req.method, path: c.req.path, status, ms };
38
+ if (status >= 500) log.error(entry, "request failed");
39
+ else log.info(entry, "request");
40
+ });
41
+ }
42
+
43
+ export interface CreateAppOptions<E extends HttpEnv> {
44
+ logger: CoreLogger;
45
+ cors: { origins: string[]; exposeHeaders?: string[]; allowHeaders?: string[] };
46
+ bodyLimitBytes: number;
47
+ /** initial values for the app's own variables, e.g. `{ principal: null, language: "en" }` */
48
+ defaults?: Defaults<E>;
49
+ }
50
+
51
+ /** A plain OpenAPIHono app with request context, secure headers, CORS, body limit, /health and /ready. */
52
+ export function createApp<E extends HttpEnv>(options: CreateAppOptions<E>): OpenAPIHono<E> {
53
+ const app = new OpenAPIHono<E>({ defaultHook: validationHook });
54
+ app.use(requestContext<E>(options.logger, options.defaults) as MiddlewareHandler<E>);
55
+ app.use(secureHeaders());
56
+ app.use(
57
+ cors({
58
+ origin: options.cors.origins,
59
+ credentials: true,
60
+ allowHeaders: options.cors.allowHeaders ?? [
61
+ "Content-Type",
62
+ "Authorization",
63
+ "X-Request-Id",
64
+ "Accept-Language",
65
+ ],
66
+ exposeHeaders: options.cors.exposeHeaders ?? ["X-Request-Id"],
67
+ }),
68
+ );
69
+ app.use(bodyLimit({ maxSize: options.bodyLimitBytes }));
70
+ app.get("/health", (c) => c.json({ status: "ok" }));
71
+ app.get("/ready", async (c) => {
72
+ const result = await runReadinessChecks();
73
+ return c.json(result, result.ready ? 200 : 503);
74
+ });
75
+ return app;
76
+ }
77
+
78
+ export interface RouteDefinition<A extends string = string, E extends HttpEnv = HttpEnv> {
79
+ /** which HTTP app serves these routes */
80
+ app: A;
81
+ /** mounted under the prefix + basePath, e.g. "/notes" */
82
+ basePath: string;
83
+ router: OpenAPIHono<E>;
84
+ }
85
+
86
+ /** Default export of every `routes/*.routes.ts`; discovered by `tatara sync`. */
87
+ export function defineRoutes<D extends RouteDefinition<string, any>>(definition: D): D {
88
+ return definition;
89
+ }
90
+
91
+ /** A router for one routes file. Attach middleware per route or per path, never `use("*")`. */
92
+ export function createRouter<E extends HttpEnv>(): OpenAPIHono<E> {
93
+ return new OpenAPIHono<E>({ defaultHook: validationHook });
94
+ }
95
+
96
+ /** Mounts the discovered routes that belong to `app` under `prefix`. */
97
+ export function mountRoutes<E extends HttpEnv>(
98
+ app: OpenAPIHono<E>,
99
+ routes: readonly RouteDefinition<string, any>[],
100
+ options: { app: string; prefix: string },
101
+ ): void {
102
+ for (const definition of routes) {
103
+ if (definition.app === options.app)
104
+ app.route(`${options.prefix}${definition.basePath}`, definition.router);
105
+ }
106
+ }
107
+
108
+ type OpenApiDocument = ReturnType<OpenAPIHono["getOpenAPI31Document"]>;
109
+
110
+ /** `/openapi.json` (built per request so `extend` can add to it) and the Scalar UI at `/docs`. */
111
+ export function serveOpenApi<E extends HttpEnv>(
112
+ app: OpenAPIHono<E>,
113
+ options: {
114
+ title: string;
115
+ version?: string;
116
+ extend?: (document: OpenApiDocument) => void | Promise<void>;
117
+ },
118
+ ): void {
119
+ const config = {
120
+ openapi: "3.1.0",
121
+ info: { title: options.title, version: options.version ?? "1.0.0" },
122
+ } as const;
123
+ app.get("/openapi.json", async (c) => {
124
+ const document = app.getOpenAPI31Document(config);
125
+ await options.extend?.(document);
126
+ return c.json(document);
127
+ });
128
+ app.get("/docs", Scalar({ url: "/openapi.json", pageTitle: options.title }));
129
+ }
130
+
131
+ /** The error envelope for every error, plus a 404 for unknown routes. */
132
+ export function handleErrors<E extends HttpEnv>(
133
+ app: OpenAPIHono<E>,
134
+ options: { message?: (err: AppError, c: Context<E>) => string } = {},
135
+ ): void {
136
+ app.onError((err, c) => {
137
+ if (err instanceof AppError) {
138
+ const message = options.message ? options.message(err, c as Context<E>) : err.message;
139
+ return c.json(
140
+ {
141
+ success: false as const,
142
+ error: message,
143
+ code: err.code,
144
+ ...(err.details === undefined ? {} : { details: err.details }),
145
+ },
146
+ err.status,
147
+ );
148
+ }
149
+ if (err instanceof HTTPException) {
150
+ return c.json(
151
+ { success: false as const, error: err.message || "Request failed", code: "HTTP_ERROR" },
152
+ err.status,
153
+ );
154
+ }
155
+ (c.get("logger") as CoreLogger).error({ err }, "unhandled error");
156
+ return c.json(
157
+ { success: false as const, error: "Something went wrong", code: "INTERNAL_ERROR" },
158
+ 500,
159
+ );
160
+ });
161
+ app.notFound((c) =>
162
+ c.json({ success: false as const, error: "Route not found", code: "NOT_FOUND" }, 404),
163
+ );
164
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,79 @@
1
+ import type { ContentfulStatusCode } from "hono/utils/http-status";
2
+
3
+ export class AppError extends Error {
4
+ readonly status: ContentfulStatusCode;
5
+ readonly code: string;
6
+ readonly details?: unknown;
7
+ /** True when the message is generic, so the response may replace it with `errors.<code>` in the caller's language. */
8
+ readonly translatable: boolean;
9
+
10
+ constructor(
11
+ status: ContentfulStatusCode,
12
+ code: string,
13
+ message: string,
14
+ details?: unknown,
15
+ translatable = false,
16
+ ) {
17
+ super(message);
18
+ this.name = new.target.name;
19
+ this.status = status;
20
+ this.code = code;
21
+ this.details = details;
22
+ this.translatable = translatable;
23
+ }
24
+ }
25
+
26
+ export class ValidationError extends AppError {
27
+ constructor(message?: string, details?: unknown) {
28
+ super(400, "VALIDATION_ERROR", message ?? "Invalid request", details, message === undefined);
29
+ }
30
+ }
31
+
32
+ export class BusinessRuleError extends AppError {
33
+ constructor(code: string, message: string, details?: unknown) {
34
+ // codes are specific to the rule, so a translation for the code is never less precise
35
+ super(400, code, message, details, true);
36
+ }
37
+ }
38
+
39
+ export class UnauthorizedError extends AppError {
40
+ constructor(message?: string) {
41
+ super(
42
+ 401,
43
+ "UNAUTHORIZED",
44
+ message ?? "Authentication required",
45
+ undefined,
46
+ message === undefined,
47
+ );
48
+ }
49
+ }
50
+
51
+ export class ForbiddenError extends AppError {
52
+ constructor(message?: string) {
53
+ super(
54
+ 403,
55
+ "FORBIDDEN",
56
+ message ?? "You do not have permission to do this",
57
+ undefined,
58
+ message === undefined,
59
+ );
60
+ }
61
+ }
62
+
63
+ export class NotFoundError extends AppError {
64
+ constructor(resource?: string) {
65
+ super(
66
+ 404,
67
+ "NOT_FOUND",
68
+ `${resource ?? "Resource"} not found`,
69
+ undefined,
70
+ resource === undefined,
71
+ );
72
+ }
73
+ }
74
+
75
+ export class ConflictError extends AppError {
76
+ constructor(message?: string, details?: unknown) {
77
+ super(409, "CONFLICT", message ?? "Resource already exists", details, message === undefined);
78
+ }
79
+ }
package/src/index.ts ADDED
@@ -0,0 +1,17 @@
1
+ /** @tatara/http: plain Hono/OpenAPIHono setup, errors, the response envelope and docs. */
2
+ export {
3
+ type CreateAppOptions,
4
+ createApp,
5
+ createRouter,
6
+ defineRoutes,
7
+ type HttpEnv,
8
+ handleErrors,
9
+ mountRoutes,
10
+ type RouteDefinition,
11
+ requestContext,
12
+ serveOpenApi,
13
+ } from "./app.ts";
14
+ export * from "./errors.ts";
15
+ export * from "./response.ts";
16
+ export { startServer } from "./server.ts";
17
+ export { validationHook } from "./validation.ts";
@@ -0,0 +1,72 @@
1
+ import { z } from "@hono/zod-openapi";
2
+
3
+ export const errorSchema = z
4
+ .object({
5
+ success: z.literal(false),
6
+ error: z.string(),
7
+ code: z.string(),
8
+ details: z.unknown().optional(),
9
+ })
10
+ .openapi("Error");
11
+
12
+ export const paginationMetaSchema = z
13
+ .object({
14
+ page: z.number().int(),
15
+ pageSize: z.number().int(),
16
+ total: z.number().int(),
17
+ totalPages: z.number().int(),
18
+ })
19
+ .openapi("PaginationMeta");
20
+
21
+ export const paginationQuerySchema = z.object({
22
+ page: z.coerce.number().int().min(1).default(1),
23
+ pageSize: z.coerce.number().int().min(1).max(100).default(20),
24
+ });
25
+
26
+ export type PaginationQuery = z.infer<typeof paginationQuerySchema>;
27
+
28
+ export function successSchema<T extends z.ZodType>(data: T) {
29
+ return z.object({ success: z.literal(true), data });
30
+ }
31
+
32
+ export function paginatedSchema<T extends z.ZodType>(item: T) {
33
+ return z.object({ success: z.literal(true), data: z.array(item), meta: paginationMetaSchema });
34
+ }
35
+
36
+ export function success<T>(data: T) {
37
+ return { success: true as const, data };
38
+ }
39
+
40
+ export function paginated<T>(data: T[], page: number, pageSize: number, total: number) {
41
+ return {
42
+ success: true as const,
43
+ data,
44
+ meta: { page, pageSize, total, totalPages: Math.max(1, Math.ceil(total / pageSize)) },
45
+ };
46
+ }
47
+
48
+ export function jsonContent<T extends z.ZodType>(schema: T, description: string) {
49
+ return { content: { "application/json": { schema } }, description };
50
+ }
51
+
52
+ /** A required JSON request body. Without `required`, a request with no body skips validation. */
53
+ export function jsonBody<T extends z.ZodType>(schema: T, description: string) {
54
+ return { ...jsonContent(schema, description), required: true };
55
+ }
56
+
57
+ const ERROR_DESCRIPTIONS = {
58
+ 400: "Invalid request",
59
+ 401: "Authentication required",
60
+ 403: "Forbidden",
61
+ 404: "Not found",
62
+ 409: "Conflict",
63
+ } as const;
64
+
65
+ type ErrorStatus = keyof typeof ERROR_DESCRIPTIONS;
66
+
67
+ /** OpenAPI error responses for the given statuses, all using the shared error envelope. */
68
+ export function errorResponses<const S extends ErrorStatus[]>(...statuses: S) {
69
+ return Object.fromEntries(
70
+ statuses.map((status) => [status, jsonContent(errorSchema, ERROR_DESCRIPTIONS[status])]),
71
+ ) as { [K in S[number]]: ReturnType<typeof jsonContent<typeof errorSchema>> };
72
+ }
package/src/server.ts ADDED
@@ -0,0 +1,21 @@
1
+ import { serve } from "@hono/node-server";
2
+ import type { OpenAPIHono } from "@hono/zod-openapi";
3
+ import { type CoreLogger, handleSignals, onShutdown } from "@tatara/core";
4
+ import type { HttpEnv } from "./app.ts";
5
+
6
+ /** Starts an HTTP app; on shutdown stops accepting connections and drains in-flight requests. */
7
+ export function startServer<E extends HttpEnv>(
8
+ app: OpenAPIHono<E>,
9
+ options: { port: number; name: string; logger: CoreLogger },
10
+ ) {
11
+ const { port, name, logger } = options;
12
+ const server = serve({ fetch: app.fetch, port }, (info) => {
13
+ logger.info(
14
+ { port: info.port, app: name },
15
+ `${name} listening on http://localhost:${info.port}`,
16
+ );
17
+ });
18
+ onShutdown(`${name}-http`, () => new Promise<void>((resolve) => server.close(() => resolve())));
19
+ handleSignals(logger);
20
+ return server;
21
+ }
@@ -0,0 +1,16 @@
1
+ import type { Hook } from "@hono/zod-openapi";
2
+ import { ValidationError } from "./errors.ts";
3
+
4
+ // biome-ignore lint/suspicious/noExplicitAny: the hook is shared by every route schema
5
+ export const validationHook: Hook<any, any, any, any> = (result) => {
6
+ if (!result.success) {
7
+ throw new ValidationError(
8
+ undefined,
9
+ result.error.issues.map((issue) => ({
10
+ path: issue.path.join("."),
11
+ message: issue.message,
12
+ code: issue.code,
13
+ })),
14
+ );
15
+ }
16
+ };