@tatara/http 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mohammed Bageri
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,3 @@
1
- # Temporary Holding Version
1
+ # @tatara/http
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Part of [tatara](https://github.com/mohammed-bageri/tatara-cli).
@@ -0,0 +1,171 @@
1
+ import { Hook, OpenAPIHono, z } from "@hono/zod-openapi";
2
+ import { CoreLogger } from "@tatara/core";
3
+ import { Context, MiddlewareHandler } from "hono";
4
+ import { ContentfulStatusCode } from "hono/utils/http-status";
5
+ //#region src/errors.d.ts
6
+ export declare class AppError extends Error {
7
+ readonly status: ContentfulStatusCode;
8
+ readonly code: string;
9
+ readonly details?: unknown;
10
+ /** True when the message is generic, so the response may replace it with `errors.<code>` in the caller's language. */
11
+ readonly translatable: boolean;
12
+ constructor(status: ContentfulStatusCode, code: string, message: string, details?: unknown, translatable?: boolean);
13
+ }
14
+ export declare class ValidationError extends AppError {
15
+ constructor(message?: string, details?: unknown);
16
+ }
17
+ export declare class BusinessRuleError extends AppError {
18
+ constructor(code: string, message: string, details?: unknown);
19
+ }
20
+ export declare class UnauthorizedError extends AppError {
21
+ constructor(message?: string);
22
+ }
23
+ export declare class ForbiddenError extends AppError {
24
+ constructor(message?: string);
25
+ }
26
+ export declare class NotFoundError extends AppError {
27
+ constructor(resource?: string);
28
+ }
29
+ export declare class ConflictError extends AppError {
30
+ constructor(message?: string, details?: unknown);
31
+ }
32
+ //#endregion
33
+ //#region src/app.d.ts
34
+ /** Variables every tatara app has; apps extend them with their own (principal, language, …). */
35
+ interface HttpEnv {
36
+ Variables: {
37
+ requestId: string;
38
+ logger: CoreLogger;
39
+ };
40
+ }
41
+ type Defaults<E extends HttpEnv> = Partial<Omit<E["Variables"], "requestId" | "logger">>;
42
+ /** Sets requestId, a request-scoped logger and default variables; logs one line per request. */
43
+ export declare function requestContext<E extends HttpEnv>(logger: CoreLogger, defaults?: Defaults<E>): MiddlewareHandler<E, string, {}, Response>;
44
+ interface CreateAppOptions<E extends HttpEnv> {
45
+ logger: CoreLogger;
46
+ cors: {
47
+ origins: string[];
48
+ exposeHeaders?: string[];
49
+ allowHeaders?: string[];
50
+ };
51
+ bodyLimitBytes: number;
52
+ /** initial values for the app's own variables, e.g. `{ principal: null, language: "en" }` */
53
+ defaults?: Defaults<E>;
54
+ }
55
+ /** A plain OpenAPIHono app with request context, secure headers, CORS, body limit, /health and /ready. */
56
+ export declare function createApp<E extends HttpEnv>(options: CreateAppOptions<E>): OpenAPIHono<E>;
57
+ interface RouteDefinition<A extends string = string, E extends HttpEnv = HttpEnv> {
58
+ /** which HTTP app serves these routes */
59
+ app: A;
60
+ /** mounted under the prefix + basePath, e.g. "/notes" */
61
+ basePath: string;
62
+ router: OpenAPIHono<E>;
63
+ }
64
+ /** Default export of every `routes/*.routes.ts`; discovered by `tatara sync`. */
65
+ export declare function defineRoutes<D extends RouteDefinition<string, any>>(definition: D): D;
66
+ /** A router for one routes file. Attach middleware per route or per path, never `use("*")`. */
67
+ export declare function createRouter<E extends HttpEnv>(): OpenAPIHono<E>;
68
+ /** Mounts the discovered routes that belong to `app` under `prefix`. */
69
+ export declare function mountRoutes<E extends HttpEnv>(app: OpenAPIHono<E>, routes: readonly RouteDefinition<string, any>[], options: {
70
+ app: string;
71
+ prefix: string;
72
+ }): void;
73
+ type OpenApiDocument = ReturnType<OpenAPIHono["getOpenAPI31Document"]>;
74
+ /** `/openapi.json` (built per request so `extend` can add to it) and the Scalar UI at `/docs`. */
75
+ export declare function serveOpenApi<E extends HttpEnv>(app: OpenAPIHono<E>, options: {
76
+ title: string;
77
+ version?: string;
78
+ extend?: (document: OpenApiDocument) => void | Promise<void>;
79
+ }): void;
80
+ /** The error envelope for every error, plus a 404 for unknown routes. */
81
+ export declare function handleErrors<E extends HttpEnv>(app: OpenAPIHono<E>, options?: {
82
+ message?: (err: AppError, c: Context<E>) => string;
83
+ }): void;
84
+ //#endregion
85
+ //#region src/response.d.ts
86
+ export declare const errorSchema: z.ZodObject<{
87
+ success: z.ZodLiteral<false>;
88
+ error: z.ZodString;
89
+ code: z.ZodString;
90
+ details: z.ZodOptional<z.ZodUnknown>;
91
+ }, z.core.$strip>;
92
+ export declare const paginationMetaSchema: z.ZodObject<{
93
+ page: z.ZodNumber;
94
+ pageSize: z.ZodNumber;
95
+ total: z.ZodNumber;
96
+ totalPages: z.ZodNumber;
97
+ }, z.core.$strip>;
98
+ export declare const paginationQuerySchema: z.ZodObject<{
99
+ page: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
100
+ pageSize: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
101
+ }, z.core.$strip>;
102
+ export type PaginationQuery = z.infer<typeof paginationQuerySchema>;
103
+ export declare function successSchema<T extends z.ZodType>(data: T): z.ZodObject<{
104
+ success: z.ZodLiteral<true>;
105
+ data: T;
106
+ }, z.core.$strip>;
107
+ export declare function paginatedSchema<T extends z.ZodType>(item: T): z.ZodObject<{
108
+ success: z.ZodLiteral<true>;
109
+ data: z.ZodArray<T>;
110
+ meta: z.ZodObject<{
111
+ page: z.ZodNumber;
112
+ pageSize: z.ZodNumber;
113
+ total: z.ZodNumber;
114
+ totalPages: z.ZodNumber;
115
+ }, z.core.$strip>;
116
+ }, z.core.$strip>;
117
+ export declare function success<T>(data: T): {
118
+ success: true;
119
+ data: T;
120
+ };
121
+ export declare function paginated<T>(data: T[], page: number, pageSize: number, total: number): {
122
+ success: true;
123
+ data: T[];
124
+ meta: {
125
+ page: number;
126
+ pageSize: number;
127
+ total: number;
128
+ totalPages: number;
129
+ };
130
+ };
131
+ export declare function jsonContent<T extends z.ZodType>(schema: T, description: string): {
132
+ content: {
133
+ "application/json": {
134
+ schema: T;
135
+ };
136
+ };
137
+ description: string;
138
+ };
139
+ /** A required JSON request body. Without `required`, a request with no body skips validation. */
140
+ export declare function jsonBody<T extends z.ZodType>(schema: T, description: string): {
141
+ content: {
142
+ "application/json": {
143
+ schema: T;
144
+ };
145
+ };
146
+ description: string;
147
+ required: boolean;
148
+ };
149
+ declare const ERROR_DESCRIPTIONS: {
150
+ readonly 400: "Invalid request";
151
+ readonly 401: "Authentication required";
152
+ readonly 403: "Forbidden";
153
+ readonly 404: "Not found";
154
+ readonly 409: "Conflict";
155
+ };
156
+ type ErrorStatus = keyof typeof ERROR_DESCRIPTIONS;
157
+ /** OpenAPI error responses for the given statuses, all using the shared error envelope. */
158
+ export declare function errorResponses<const S extends ErrorStatus[]>(...statuses: S): { [K in S[number]]: ReturnType<typeof jsonContent<typeof errorSchema>>; };
159
+ //#endregion
160
+ //#region src/server.d.ts
161
+ /** Starts an HTTP app; on shutdown stops accepting connections and drains in-flight requests. */
162
+ export declare function startServer<E extends HttpEnv>(app: OpenAPIHono<E>, options: {
163
+ port: number;
164
+ name: string;
165
+ logger: CoreLogger;
166
+ }): import("@hono/node-server").ServerType;
167
+ //#endregion
168
+ //#region src/validation.d.ts
169
+ export declare const validationHook: Hook<any, any, any, any>;
170
+ //#endregion
171
+ export type { CreateAppOptions, HttpEnv, RouteDefinition };
package/dist/index.mjs ADDED
@@ -0,0 +1,270 @@
1
+ import { OpenAPIHono, z } from "@hono/zod-openapi";
2
+ import { Scalar } from "@scalar/hono-api-reference";
3
+ import { handleSignals, onShutdown, runReadinessChecks } from "@tatara/core";
4
+ import { bodyLimit } from "hono/body-limit";
5
+ import { cors } from "hono/cors";
6
+ import { createMiddleware } from "hono/factory";
7
+ import { HTTPException } from "hono/http-exception";
8
+ import { secureHeaders } from "hono/secure-headers";
9
+ import { serve } from "@hono/node-server";
10
+ //#region src/errors.ts
11
+ var AppError = class extends Error {
12
+ status;
13
+ code;
14
+ details;
15
+ /** True when the message is generic, so the response may replace it with `errors.<code>` in the caller's language. */
16
+ translatable;
17
+ constructor(status, code, message, details, translatable = false) {
18
+ super(message);
19
+ this.name = new.target.name;
20
+ this.status = status;
21
+ this.code = code;
22
+ this.details = details;
23
+ this.translatable = translatable;
24
+ }
25
+ };
26
+ var ValidationError = class extends AppError {
27
+ constructor(message, details) {
28
+ super(400, "VALIDATION_ERROR", message ?? "Invalid request", details, message === void 0);
29
+ }
30
+ };
31
+ var BusinessRuleError = class extends AppError {
32
+ constructor(code, message, details) {
33
+ super(400, code, message, details, true);
34
+ }
35
+ };
36
+ var UnauthorizedError = class extends AppError {
37
+ constructor(message) {
38
+ super(401, "UNAUTHORIZED", message ?? "Authentication required", void 0, message === void 0);
39
+ }
40
+ };
41
+ var ForbiddenError = class extends AppError {
42
+ constructor(message) {
43
+ super(403, "FORBIDDEN", message ?? "You do not have permission to do this", void 0, message === void 0);
44
+ }
45
+ };
46
+ var NotFoundError = class extends AppError {
47
+ constructor(resource) {
48
+ super(404, "NOT_FOUND", `${resource ?? "Resource"} not found`, void 0, resource === void 0);
49
+ }
50
+ };
51
+ var ConflictError = class extends AppError {
52
+ constructor(message, details) {
53
+ super(409, "CONFLICT", message ?? "Resource already exists", details, message === void 0);
54
+ }
55
+ };
56
+ //#endregion
57
+ //#region src/validation.ts
58
+ const validationHook = (result) => {
59
+ if (!result.success) throw new ValidationError(void 0, result.error.issues.map((issue) => ({
60
+ path: issue.path.join("."),
61
+ message: issue.message,
62
+ code: issue.code
63
+ })));
64
+ };
65
+ //#endregion
66
+ //#region src/app.ts
67
+ const REQUEST_ID = /^[\w-]{8,128}$/;
68
+ /** Sets requestId, a request-scoped logger and default variables; logs one line per request. */
69
+ function requestContext(logger, defaults = {}) {
70
+ return createMiddleware(async (c, next) => {
71
+ const incoming = c.req.header("x-request-id");
72
+ const requestId = incoming && REQUEST_ID.test(incoming) ? incoming : crypto.randomUUID();
73
+ const log = logger.child({ requestId });
74
+ const set = c.set;
75
+ set("requestId", requestId);
76
+ set("logger", log);
77
+ for (const [key, value] of Object.entries(defaults)) set(key, value);
78
+ c.header("x-request-id", requestId);
79
+ const start = performance.now();
80
+ await next();
81
+ const ms = Math.round(performance.now() - start);
82
+ const status = c.res.status;
83
+ const entry = {
84
+ method: c.req.method,
85
+ path: c.req.path,
86
+ status,
87
+ ms
88
+ };
89
+ if (status >= 500) log.error(entry, "request failed");
90
+ else log.info(entry, "request");
91
+ });
92
+ }
93
+ /** A plain OpenAPIHono app with request context, secure headers, CORS, body limit, /health and /ready. */
94
+ function createApp(options) {
95
+ const app = new OpenAPIHono({ defaultHook: validationHook });
96
+ app.use(requestContext(options.logger, options.defaults));
97
+ app.use(secureHeaders());
98
+ app.use(cors({
99
+ origin: options.cors.origins,
100
+ credentials: true,
101
+ allowHeaders: options.cors.allowHeaders ?? [
102
+ "Content-Type",
103
+ "Authorization",
104
+ "X-Request-Id",
105
+ "Accept-Language"
106
+ ],
107
+ exposeHeaders: options.cors.exposeHeaders ?? ["X-Request-Id"]
108
+ }));
109
+ app.use(bodyLimit({ maxSize: options.bodyLimitBytes }));
110
+ app.get("/health", (c) => c.json({ status: "ok" }));
111
+ app.get("/ready", async (c) => {
112
+ const result = await runReadinessChecks();
113
+ return c.json(result, result.ready ? 200 : 503);
114
+ });
115
+ return app;
116
+ }
117
+ /** Default export of every `routes/*.routes.ts`; discovered by `tatara sync`. */
118
+ function defineRoutes(definition) {
119
+ return definition;
120
+ }
121
+ /** A router for one routes file. Attach middleware per route or per path, never `use("*")`. */
122
+ function createRouter() {
123
+ return new OpenAPIHono({ defaultHook: validationHook });
124
+ }
125
+ /** Mounts the discovered routes that belong to `app` under `prefix`. */
126
+ function mountRoutes(app, routes, options) {
127
+ for (const definition of routes) if (definition.app === options.app) app.route(`${options.prefix}${definition.basePath}`, definition.router);
128
+ }
129
+ /** `/openapi.json` (built per request so `extend` can add to it) and the Scalar UI at `/docs`. */
130
+ function serveOpenApi(app, options) {
131
+ const config = {
132
+ openapi: "3.1.0",
133
+ info: {
134
+ title: options.title,
135
+ version: options.version ?? "1.0.0"
136
+ }
137
+ };
138
+ app.get("/openapi.json", async (c) => {
139
+ const document = app.getOpenAPI31Document(config);
140
+ await options.extend?.(document);
141
+ return c.json(document);
142
+ });
143
+ app.get("/docs", Scalar({
144
+ url: "/openapi.json",
145
+ pageTitle: options.title
146
+ }));
147
+ }
148
+ /** The error envelope for every error, plus a 404 for unknown routes. */
149
+ function handleErrors(app, options = {}) {
150
+ app.onError((err, c) => {
151
+ if (err instanceof AppError) {
152
+ const message = options.message ? options.message(err, c) : err.message;
153
+ return c.json({
154
+ success: false,
155
+ error: message,
156
+ code: err.code,
157
+ ...err.details === void 0 ? {} : { details: err.details }
158
+ }, err.status);
159
+ }
160
+ if (err instanceof HTTPException) return c.json({
161
+ success: false,
162
+ error: err.message || "Request failed",
163
+ code: "HTTP_ERROR"
164
+ }, err.status);
165
+ c.get("logger").error({ err }, "unhandled error");
166
+ return c.json({
167
+ success: false,
168
+ error: "Something went wrong",
169
+ code: "INTERNAL_ERROR"
170
+ }, 500);
171
+ });
172
+ app.notFound((c) => c.json({
173
+ success: false,
174
+ error: "Route not found",
175
+ code: "NOT_FOUND"
176
+ }, 404));
177
+ }
178
+ //#endregion
179
+ //#region src/response.ts
180
+ const errorSchema = z.object({
181
+ success: z.literal(false),
182
+ error: z.string(),
183
+ code: z.string(),
184
+ details: z.unknown().optional()
185
+ }).openapi("Error");
186
+ const paginationMetaSchema = z.object({
187
+ page: z.number().int(),
188
+ pageSize: z.number().int(),
189
+ total: z.number().int(),
190
+ totalPages: z.number().int()
191
+ }).openapi("PaginationMeta");
192
+ const paginationQuerySchema = z.object({
193
+ page: z.coerce.number().int().min(1).default(1),
194
+ pageSize: z.coerce.number().int().min(1).max(100).default(20)
195
+ });
196
+ function successSchema(data) {
197
+ return z.object({
198
+ success: z.literal(true),
199
+ data
200
+ });
201
+ }
202
+ function paginatedSchema(item) {
203
+ return z.object({
204
+ success: z.literal(true),
205
+ data: z.array(item),
206
+ meta: paginationMetaSchema
207
+ });
208
+ }
209
+ function success(data) {
210
+ return {
211
+ success: true,
212
+ data
213
+ };
214
+ }
215
+ function paginated(data, page, pageSize, total) {
216
+ return {
217
+ success: true,
218
+ data,
219
+ meta: {
220
+ page,
221
+ pageSize,
222
+ total,
223
+ totalPages: Math.max(1, Math.ceil(total / pageSize))
224
+ }
225
+ };
226
+ }
227
+ function jsonContent(schema, description) {
228
+ return {
229
+ content: { "application/json": { schema } },
230
+ description
231
+ };
232
+ }
233
+ /** A required JSON request body. Without `required`, a request with no body skips validation. */
234
+ function jsonBody(schema, description) {
235
+ return {
236
+ ...jsonContent(schema, description),
237
+ required: true
238
+ };
239
+ }
240
+ const ERROR_DESCRIPTIONS = {
241
+ 400: "Invalid request",
242
+ 401: "Authentication required",
243
+ 403: "Forbidden",
244
+ 404: "Not found",
245
+ 409: "Conflict"
246
+ };
247
+ /** OpenAPI error responses for the given statuses, all using the shared error envelope. */
248
+ function errorResponses(...statuses) {
249
+ return Object.fromEntries(statuses.map((status) => [status, jsonContent(errorSchema, ERROR_DESCRIPTIONS[status])]));
250
+ }
251
+ //#endregion
252
+ //#region src/server.ts
253
+ /** Starts an HTTP app; on shutdown stops accepting connections and drains in-flight requests. */
254
+ function startServer(app, options) {
255
+ const { port, name, logger } = options;
256
+ const server = serve({
257
+ fetch: app.fetch,
258
+ port
259
+ }, (info) => {
260
+ logger.info({
261
+ port: info.port,
262
+ app: name
263
+ }, `${name} listening on http://localhost:${info.port}`);
264
+ });
265
+ onShutdown(`${name}-http`, () => new Promise((resolve) => server.close(() => resolve())));
266
+ handleSignals(logger);
267
+ return server;
268
+ }
269
+ //#endregion
270
+ export { AppError, BusinessRuleError, ConflictError, ForbiddenError, NotFoundError, UnauthorizedError, ValidationError, createApp, createRouter, defineRoutes, errorResponses, errorSchema, handleErrors, jsonBody, jsonContent, mountRoutes, paginated, paginatedSchema, paginationMetaSchema, paginationQuerySchema, requestContext, serveOpenApi, startServer, success, successSchema, validationHook };
package/package.json CHANGED
@@ -1,6 +1,52 @@
1
1
  {
2
2
  "name": "@tatara/http",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "tatara HTTP: Hono app setup, errors, envelope, OpenAPI docs and route mounting",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "sideEffects": false,
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.mts",
11
+ "import": "./dist/index.mjs"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "engines": {
18
+ "node": ">=24"
19
+ },
20
+ "peerDependencies": {
21
+ "hono": "^4.13.13",
22
+ "@hono/zod-openapi": "^1.6.3",
23
+ "@hono/node-server": "^2.1.5",
24
+ "@scalar/hono-api-reference": "^0.12.12",
25
+ "@tatara/core": "^0.1.0"
26
+ },
27
+ "devDependencies": {
28
+ "hono": "^4.13.13",
29
+ "@hono/zod-openapi": "^1.6.3",
30
+ "@hono/node-server": "^2.1.5",
31
+ "@scalar/hono-api-reference": "^0.12.12",
32
+ "@tatara/core": "^0.1.0",
33
+ "pino": "^10.4.0",
34
+ "zod": "^4.6.7"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/mohammed-bageri/tatara-cli.git",
42
+ "directory": "packages/http"
43
+ },
44
+ "homepage": "https://github.com/mohammed-bageri/tatara-cli#readme",
45
+ "bugs": {
46
+ "url": "https://github.com/mohammed-bageri/tatara-cli/issues"
47
+ },
48
+ "scripts": {
49
+ "build": "tsdown",
50
+ "typecheck": "tsc --noEmit"
51
+ }
6
52
  }