@tatara/http 0.1.1 → 0.1.2

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/dist/index.d.mts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Hook, OpenAPIHono, z } from "@hono/zod-openapi";
2
2
  import { CoreLogger } from "@tatara/core";
3
+ import { ServerType } from "@hono/node-server";
3
4
  import { Context, MiddlewareHandler } from "hono";
4
5
  import { ContentfulStatusCode } from "hono/utils/http-status";
5
6
  //#region src/errors.d.ts
@@ -29,6 +30,26 @@ export declare class NotFoundError extends AppError {
29
30
  export declare class ConflictError extends AppError {
30
31
  constructor(message?: string, details?: unknown);
31
32
  }
33
+ export declare class TooManyRequestsError extends AppError {
34
+ /** seconds until the caller may try again, sent as Retry-After */
35
+ readonly retryAfterSeconds: number;
36
+ constructor(retryAfterSeconds: number);
37
+ }
38
+ export declare class RequestTimeoutError extends AppError {
39
+ constructor();
40
+ }
41
+ //#endregion
42
+ //#region src/timeout.d.ts
43
+ interface TimeoutOptions {
44
+ defaultMs: number;
45
+ /** longer (or shorter) limits by path: "/api/uploads/*" matches everything under it */
46
+ paths?: Record<string, number>;
47
+ }
48
+ /**
49
+ * Answers 504 REQUEST_TIMEOUT when a handler takes longer than its path's limit. The handler isn't
50
+ * stopped: what it does after the 504 still happens, so give slow paths a longer limit instead.
51
+ */
52
+ export declare function requestTimeout(options: TimeoutOptions): MiddlewareHandler;
32
53
  //#endregion
33
54
  //#region src/app.d.ts
34
55
  /** Variables every tatara app has; apps extend them with their own (principal, language, …). */
@@ -43,15 +64,20 @@ type Defaults<E extends HttpEnv> = Partial<Omit<E["Variables"], "requestId" | "l
43
64
  export declare function requestContext<E extends HttpEnv>(logger: CoreLogger, defaults?: Defaults<E>): MiddlewareHandler<E, string, {}, Response>;
44
65
  interface CreateAppOptions<E extends HttpEnv> {
45
66
  logger: CoreLogger;
67
+ /** origins are exact (`https://app.example.com`) or one-label patterns (`https://*.example.com`) */
46
68
  cors: {
47
69
  origins: string[];
48
70
  exposeHeaders?: string[];
49
71
  allowHeaders?: string[];
50
72
  };
51
73
  bodyLimitBytes: number;
74
+ /** a limit on how long a handler may take, with longer ones for slow paths */
75
+ timeouts?: TimeoutOptions;
52
76
  /** initial values for the app's own variables, e.g. `{ principal: null, language: "en" }` */
53
77
  defaults?: Defaults<E>;
54
78
  }
79
+ /** Matches exact origins and `*.`-patterns, where `*` is exactly one DNS label (tenant subdomains). */
80
+ export declare function corsOrigin(origins: string[]): (origin: string) => string | null;
55
81
  /** A plain OpenAPIHono app with request context, secure headers, CORS, body limit, /health and /ready. */
56
82
  export declare function createApp<E extends HttpEnv>(options: CreateAppOptions<E>): OpenAPIHono<E>;
57
83
  interface RouteDefinition<A extends string = string, E extends HttpEnv = HttpEnv> {
@@ -82,6 +108,79 @@ export declare function handleErrors<E extends HttpEnv>(app: OpenAPIHono<E>, opt
82
108
  message?: (err: AppError, c: Context<E>) => string;
83
109
  }): void;
84
110
  //#endregion
111
+ //#region src/client-ip.d.ts
112
+ /**
113
+ * The client address from X-Forwarded-For, counting `trustedHops` proxies from the right.
114
+ * Entries to the left of those were sent by the client and can be forged.
115
+ */
116
+ export declare function trustedClientIp(forwardedFor: string | null | undefined, trustedHops: number): string | undefined;
117
+ /**
118
+ * The caller's address: from X-Forwarded-For when `trustedHops` proxies sit in front (Cloud Run,
119
+ * a load balancer: 1), else the socket's peer. Undefined when neither is known (in-process tests).
120
+ */
121
+ export declare function clientIp(c: Context, options?: {
122
+ trustedHops?: number;
123
+ }): string | undefined;
124
+ /**
125
+ * A copy of `request` whose X-Forwarded-For is only `ip`, for handlers like better-auth that key
126
+ * rate limits on that header and would otherwise trust whatever the client sent.
127
+ */
128
+ export declare function withClientIp(request: Request, ip: string | undefined): Request;
129
+ //#endregion
130
+ //#region src/cursor.d.ts
131
+ export declare const cursorQuerySchema: z.ZodObject<{
132
+ cursor: z.ZodOptional<z.ZodString>;
133
+ limit: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
134
+ }, z.core.$strip>;
135
+ export type CursorQuery = z.infer<typeof cursorQuerySchema>;
136
+ export declare const cursorMetaSchema: z.ZodObject<{
137
+ nextCursor: z.ZodNullable<z.ZodString>;
138
+ }, z.core.$strip>;
139
+ export declare function cursorPageSchema<T extends z.ZodType>(item: T): z.ZodObject<{
140
+ success: z.ZodLiteral<true>;
141
+ data: z.ZodArray<T>;
142
+ meta: z.ZodObject<{
143
+ nextCursor: z.ZodNullable<z.ZodString>;
144
+ }, z.core.$strip>;
145
+ }, z.core.$strip>;
146
+ /** An opaque cursor: base64url JSON of where the next page starts (e.g. `{ createdAt, id }`). */
147
+ export declare function encodeCursor(position: unknown): string;
148
+ /** The position in `cursor`, checked against `schema`; a 400 INVALID_CURSOR when it doesn't parse. */
149
+ export declare function decodeCursor<T extends z.ZodType>(schema: T, cursor: string): z.infer<T>;
150
+ /**
151
+ * One page from rows fetched with `limit + 1`: the extra row only says there's another page.
152
+ * `positionOf` gives the cursor position of the last row on this page.
153
+ */
154
+ export declare function cursorPage<T>(rows: T[], limit: number, positionOf: (row: T) => unknown): {
155
+ success: true;
156
+ data: T[];
157
+ meta: {
158
+ nextCursor: string | null;
159
+ };
160
+ };
161
+ //#endregion
162
+ //#region src/rate-limit.d.ts
163
+ /** What a rate limit counts with; `cache` from `@tatara/cache` is one. */
164
+ interface RateLimitCounter {
165
+ increment(key: string, options: {
166
+ ttlMs: number;
167
+ }): Promise<number>;
168
+ }
169
+ interface RateLimitOptions {
170
+ counter: RateLimitCounter;
171
+ /** names the limit in its keys, e.g. "sign-in" */
172
+ name: string;
173
+ /** who is counted: an IP, a user id; null skips the limit for this request */
174
+ key: (c: Context) => string | null | undefined | Promise<string | null | undefined>;
175
+ windowMs: number;
176
+ max: number;
177
+ }
178
+ /**
179
+ * A fixed-window limit: `max` requests per `windowMs` per key, then 429 with Retry-After.
180
+ * A failing counter lets the request through (and logs it) rather than taking the API down.
181
+ */
182
+ export declare function rateLimit(options: RateLimitOptions): MiddlewareHandler;
183
+ //#endregion
85
184
  //#region src/response.d.ts
86
185
  export declare const errorSchema: z.ZodObject<{
87
186
  success: z.ZodLiteral<false>;
@@ -152,6 +251,7 @@ declare const ERROR_DESCRIPTIONS: {
152
251
  readonly 403: "Forbidden";
153
252
  readonly 404: "Not found";
154
253
  readonly 409: "Conflict";
254
+ readonly 429: "Too many requests";
155
255
  };
156
256
  type ErrorStatus = keyof typeof ERROR_DESCRIPTIONS;
157
257
  /** OpenAPI error responses for the given statuses, all using the shared error envelope. */
@@ -163,9 +263,11 @@ export declare function startServer<E extends HttpEnv>(app: OpenAPIHono<E>, opti
163
263
  port: number;
164
264
  name: string;
165
265
  logger: CoreLogger;
166
- }): import("@hono/node-server").ServerType;
266
+ /** runs once the server exists, e.g. to attach a websocket server to it */
267
+ onServer?: (server: ServerType) => void;
268
+ }): ServerType;
167
269
  //#endregion
168
270
  //#region src/validation.d.ts
169
271
  export declare const validationHook: Hook<any, any, any, any>;
170
272
  //#endregion
171
- export type { CreateAppOptions, HttpEnv, RouteDefinition };
273
+ export type { CreateAppOptions, HttpEnv, RateLimitCounter, RateLimitOptions, RouteDefinition, TimeoutOptions };
package/dist/index.mjs CHANGED
@@ -6,6 +6,7 @@ import { cors } from "hono/cors";
6
6
  import { createMiddleware } from "hono/factory";
7
7
  import { HTTPException } from "hono/http-exception";
8
8
  import { secureHeaders } from "hono/secure-headers";
9
+ import { getConnInfo } from "@hono/node-server/conninfo";
9
10
  import { serve } from "@hono/node-server";
10
11
  //#region src/errors.ts
11
12
  var AppError = class extends Error {
@@ -53,6 +54,50 @@ var ConflictError = class extends AppError {
53
54
  super(409, "CONFLICT", message ?? "Resource already exists", details, message === void 0);
54
55
  }
55
56
  };
57
+ var TooManyRequestsError = class extends AppError {
58
+ /** seconds until the caller may try again, sent as Retry-After */
59
+ retryAfterSeconds;
60
+ constructor(retryAfterSeconds) {
61
+ super(429, "RATE_LIMITED", "Too many requests, try again later", void 0, true);
62
+ this.retryAfterSeconds = retryAfterSeconds;
63
+ }
64
+ };
65
+ var RequestTimeoutError = class extends AppError {
66
+ constructor() {
67
+ super(504, "REQUEST_TIMEOUT", "The request took too long", void 0, true);
68
+ }
69
+ };
70
+ //#endregion
71
+ //#region src/timeout.ts
72
+ function limitFor(path, options) {
73
+ let best;
74
+ for (const [pattern, ms] of Object.entries(options.paths ?? {})) {
75
+ const prefix = pattern.endsWith("/*") ? pattern.slice(0, -1) : void 0;
76
+ if ((prefix ? path.startsWith(prefix) || `${path}/` === prefix : path === pattern) && (!best || pattern.length > best.length)) best = {
77
+ length: pattern.length,
78
+ ms
79
+ };
80
+ }
81
+ return best?.ms ?? options.defaultMs;
82
+ }
83
+ /**
84
+ * Answers 504 REQUEST_TIMEOUT when a handler takes longer than its path's limit. The handler isn't
85
+ * stopped: what it does after the 504 still happens, so give slow paths a longer limit instead.
86
+ */
87
+ function requestTimeout(options) {
88
+ return createMiddleware(async (c, next) => {
89
+ const ms = limitFor(c.req.path, options);
90
+ let timer;
91
+ const expired = new Promise((_, reject) => {
92
+ timer = setTimeout(() => reject(new RequestTimeoutError()), ms);
93
+ });
94
+ try {
95
+ await Promise.race([next(), expired]);
96
+ } finally {
97
+ clearTimeout(timer);
98
+ }
99
+ });
100
+ }
56
101
  //#endregion
57
102
  //#region src/validation.ts
58
103
  const validationHook = (result) => {
@@ -90,13 +135,23 @@ function requestContext(logger, defaults = {}) {
90
135
  else log.info(entry, "request");
91
136
  });
92
137
  }
138
+ /** Matches exact origins and `*.`-patterns, where `*` is exactly one DNS label (tenant subdomains). */
139
+ function corsOrigin(origins) {
140
+ const exact = new Set(origins.filter((o) => !o.includes("*")));
141
+ const patterns = origins.filter((o) => o.includes("*")).map((o) => {
142
+ if (!/^https?:\/\/\*\.[^*]+$/.test(o)) throw new Error(`CORS origin pattern "${o}" must look like https://*.example.com`);
143
+ const escaped = o.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace("*", "[a-z0-9-]+");
144
+ return new RegExp(`^${escaped}$`, "i");
145
+ });
146
+ return (origin) => exact.has(origin) || patterns.some((p) => p.test(origin)) ? origin : null;
147
+ }
93
148
  /** A plain OpenAPIHono app with request context, secure headers, CORS, body limit, /health and /ready. */
94
149
  function createApp(options) {
95
150
  const app = new OpenAPIHono({ defaultHook: validationHook });
96
151
  app.use(requestContext(options.logger, options.defaults));
97
152
  app.use(secureHeaders());
98
153
  app.use(cors({
99
- origin: options.cors.origins,
154
+ origin: corsOrigin(options.cors.origins),
100
155
  credentials: true,
101
156
  allowHeaders: options.cors.allowHeaders ?? [
102
157
  "Content-Type",
@@ -107,6 +162,7 @@ function createApp(options) {
107
162
  exposeHeaders: options.cors.exposeHeaders ?? ["X-Request-Id"]
108
163
  }));
109
164
  app.use(bodyLimit({ maxSize: options.bodyLimitBytes }));
165
+ if (options.timeouts) app.use(requestTimeout(options.timeouts));
110
166
  app.get("/health", (c) => c.json({ status: "ok" }));
111
167
  app.get("/ready", async (c) => {
112
168
  const result = await runReadinessChecks();
@@ -149,6 +205,7 @@ function serveOpenApi(app, options) {
149
205
  function handleErrors(app, options = {}) {
150
206
  app.onError((err, c) => {
151
207
  if (err instanceof AppError) {
208
+ if (err instanceof TooManyRequestsError) c.header("Retry-After", String(err.retryAfterSeconds));
152
209
  const message = options.message ? options.message(err, c) : err.message;
153
210
  return c.json({
154
211
  success: false,
@@ -176,6 +233,110 @@ function handleErrors(app, options = {}) {
176
233
  }, 404));
177
234
  }
178
235
  //#endregion
236
+ //#region src/client-ip.ts
237
+ /**
238
+ * The client address from X-Forwarded-For, counting `trustedHops` proxies from the right.
239
+ * Entries to the left of those were sent by the client and can be forged.
240
+ */
241
+ function trustedClientIp(forwardedFor, trustedHops) {
242
+ if (!forwardedFor || trustedHops <= 0) return void 0;
243
+ const hops = forwardedFor.split(",").map((entry) => entry.trim()).filter(Boolean);
244
+ if (hops.length === 0) return void 0;
245
+ return hops[Math.max(0, hops.length - trustedHops)];
246
+ }
247
+ /**
248
+ * The caller's address: from X-Forwarded-For when `trustedHops` proxies sit in front (Cloud Run,
249
+ * a load balancer: 1), else the socket's peer. Undefined when neither is known (in-process tests).
250
+ */
251
+ function clientIp(c, options = {}) {
252
+ const forwarded = trustedClientIp(c.req.header("x-forwarded-for"), options.trustedHops ?? 0);
253
+ if (forwarded) return forwarded;
254
+ try {
255
+ return getConnInfo(c).remote.address;
256
+ } catch {
257
+ return;
258
+ }
259
+ }
260
+ /**
261
+ * A copy of `request` whose X-Forwarded-For is only `ip`, for handlers like better-auth that key
262
+ * rate limits on that header and would otherwise trust whatever the client sent.
263
+ */
264
+ function withClientIp(request, ip) {
265
+ const headers = new Headers(request.headers);
266
+ if (ip) headers.set("x-forwarded-for", ip);
267
+ else headers.delete("x-forwarded-for");
268
+ return new Request(request, { headers });
269
+ }
270
+ //#endregion
271
+ //#region src/cursor.ts
272
+ const cursorQuerySchema = z.object({
273
+ cursor: z.string().max(512).optional(),
274
+ limit: z.coerce.number().int().min(1).max(100).default(20)
275
+ });
276
+ const cursorMetaSchema = z.object({ nextCursor: z.string().nullable() }).openapi("CursorMeta");
277
+ function cursorPageSchema(item) {
278
+ return z.object({
279
+ success: z.literal(true),
280
+ data: z.array(item),
281
+ meta: cursorMetaSchema
282
+ });
283
+ }
284
+ /** An opaque cursor: base64url JSON of where the next page starts (e.g. `{ createdAt, id }`). */
285
+ function encodeCursor(position) {
286
+ return Buffer.from(JSON.stringify(position)).toString("base64url");
287
+ }
288
+ /** The position in `cursor`, checked against `schema`; a 400 INVALID_CURSOR when it doesn't parse. */
289
+ function decodeCursor(schema, cursor) {
290
+ try {
291
+ return schema.parse(JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")));
292
+ } catch {
293
+ throw new AppError(400, "INVALID_CURSOR", "Invalid cursor", void 0, true);
294
+ }
295
+ }
296
+ /**
297
+ * One page from rows fetched with `limit + 1`: the extra row only says there's another page.
298
+ * `positionOf` gives the cursor position of the last row on this page.
299
+ */
300
+ function cursorPage(rows, limit, positionOf) {
301
+ const data = rows.slice(0, limit);
302
+ const last = data.at(-1);
303
+ return {
304
+ success: true,
305
+ data,
306
+ meta: { nextCursor: rows.length > limit && last !== void 0 ? encodeCursor(positionOf(last)) : null }
307
+ };
308
+ }
309
+ //#endregion
310
+ //#region src/rate-limit.ts
311
+ /**
312
+ * A fixed-window limit: `max` requests per `windowMs` per key, then 429 with Retry-After.
313
+ * A failing counter lets the request through (and logs it) rather than taking the API down.
314
+ */
315
+ function rateLimit(options) {
316
+ if (!(options.max > 0 && options.windowMs > 0)) throw new Error("rateLimit needs a positive max and windowMs");
317
+ const retryAfter = Math.ceil(options.windowMs / 1e3);
318
+ return createMiddleware(async (c, next) => {
319
+ const who = await options.key(c);
320
+ if (who) {
321
+ let count;
322
+ try {
323
+ count = await options.counter.increment(`rate:${options.name}:${who}`, { ttlMs: options.windowMs });
324
+ } catch (err) {
325
+ c.get("logger")?.warn({
326
+ err,
327
+ limit: options.name
328
+ }, "rate limit counter unavailable, allowing the request");
329
+ }
330
+ if (count !== void 0) {
331
+ c.header("RateLimit-Limit", String(options.max));
332
+ c.header("RateLimit-Remaining", String(Math.max(0, options.max - count)));
333
+ if (count > options.max) throw new TooManyRequestsError(retryAfter);
334
+ }
335
+ }
336
+ await next();
337
+ });
338
+ }
339
+ //#endregion
179
340
  //#region src/response.ts
180
341
  const errorSchema = z.object({
181
342
  success: z.literal(false),
@@ -242,7 +403,8 @@ const ERROR_DESCRIPTIONS = {
242
403
  401: "Authentication required",
243
404
  403: "Forbidden",
244
405
  404: "Not found",
245
- 409: "Conflict"
406
+ 409: "Conflict",
407
+ 429: "Too many requests"
246
408
  };
247
409
  /** OpenAPI error responses for the given statuses, all using the shared error envelope. */
248
410
  function errorResponses(...statuses) {
@@ -262,9 +424,10 @@ function startServer(app, options) {
262
424
  app: name
263
425
  }, `${name} listening on http://localhost:${info.port}`);
264
426
  });
427
+ options.onServer?.(server);
265
428
  onShutdown(`${name}-http`, () => new Promise((resolve) => server.close(() => resolve())));
266
429
  handleSignals(logger);
267
430
  return server;
268
431
  }
269
432
  //#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 };
433
+ export { AppError, BusinessRuleError, ConflictError, ForbiddenError, NotFoundError, RequestTimeoutError, TooManyRequestsError, UnauthorizedError, ValidationError, clientIp, corsOrigin, createApp, createRouter, cursorMetaSchema, cursorPage, cursorPageSchema, cursorQuerySchema, decodeCursor, defineRoutes, encodeCursor, errorResponses, errorSchema, handleErrors, jsonBody, jsonContent, mountRoutes, paginated, paginatedSchema, paginationMetaSchema, paginationQuerySchema, rateLimit, requestContext, requestTimeout, serveOpenApi, startServer, success, successSchema, trustedClientIp, validationHook, withClientIp };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tatara/http",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "tatara HTTP: Hono app setup, errors, envelope, OpenAPI docs and route mounting",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -24,14 +24,14 @@
24
24
  "@hono/zod-openapi": "^1.6.3",
25
25
  "@hono/node-server": "^2.1.5",
26
26
  "@scalar/hono-api-reference": "^0.12.12",
27
- "@tatara/core": "^0.1.1"
27
+ "@tatara/core": "^0.1.2"
28
28
  },
29
29
  "devDependencies": {
30
30
  "hono": "^4.13.13",
31
31
  "@hono/zod-openapi": "^1.6.3",
32
32
  "@hono/node-server": "^2.1.5",
33
33
  "@scalar/hono-api-reference": "^0.12.12",
34
- "@tatara/core": "^0.1.1",
34
+ "@tatara/core": "^0.1.2",
35
35
  "pino": "^10.4.0",
36
36
  "zod": "^4.6.7"
37
37
  },
package/src/app.ts CHANGED
@@ -7,7 +7,8 @@ import { cors } from "hono/cors";
7
7
  import { createMiddleware } from "hono/factory";
8
8
  import { HTTPException } from "hono/http-exception";
9
9
  import { secureHeaders } from "hono/secure-headers";
10
- import { AppError } from "./errors.ts";
10
+ import { AppError, TooManyRequestsError } from "./errors.ts";
11
+ import { requestTimeout, type TimeoutOptions } from "./timeout.ts";
11
12
  import { validationHook } from "./validation.ts";
12
13
 
13
14
  /** Variables every tatara app has; apps extend them with their own (principal, language, …). */
@@ -42,12 +43,29 @@ export function requestContext<E extends HttpEnv>(logger: CoreLogger, defaults:
42
43
 
43
44
  export interface CreateAppOptions<E extends HttpEnv> {
44
45
  logger: CoreLogger;
46
+ /** origins are exact (`https://app.example.com`) or one-label patterns (`https://*.example.com`) */
45
47
  cors: { origins: string[]; exposeHeaders?: string[]; allowHeaders?: string[] };
46
48
  bodyLimitBytes: number;
49
+ /** a limit on how long a handler may take, with longer ones for slow paths */
50
+ timeouts?: TimeoutOptions;
47
51
  /** initial values for the app's own variables, e.g. `{ principal: null, language: "en" }` */
48
52
  defaults?: Defaults<E>;
49
53
  }
50
54
 
55
+ /** Matches exact origins and `*.`-patterns, where `*` is exactly one DNS label (tenant subdomains). */
56
+ export function corsOrigin(origins: string[]): (origin: string) => string | null {
57
+ const exact = new Set(origins.filter((o) => !o.includes("*")));
58
+ const patterns = origins
59
+ .filter((o) => o.includes("*"))
60
+ .map((o) => {
61
+ if (!/^https?:\/\/\*\.[^*]+$/.test(o))
62
+ throw new Error(`CORS origin pattern "${o}" must look like https://*.example.com`);
63
+ const escaped = o.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace("*", "[a-z0-9-]+");
64
+ return new RegExp(`^${escaped}$`, "i");
65
+ });
66
+ return (origin) => (exact.has(origin) || patterns.some((p) => p.test(origin)) ? origin : null);
67
+ }
68
+
51
69
  /** A plain OpenAPIHono app with request context, secure headers, CORS, body limit, /health and /ready. */
52
70
  export function createApp<E extends HttpEnv>(options: CreateAppOptions<E>): OpenAPIHono<E> {
53
71
  const app = new OpenAPIHono<E>({ defaultHook: validationHook });
@@ -55,7 +73,7 @@ export function createApp<E extends HttpEnv>(options: CreateAppOptions<E>): Open
55
73
  app.use(secureHeaders());
56
74
  app.use(
57
75
  cors({
58
- origin: options.cors.origins,
76
+ origin: corsOrigin(options.cors.origins),
59
77
  credentials: true,
60
78
  allowHeaders: options.cors.allowHeaders ?? [
61
79
  "Content-Type",
@@ -67,6 +85,7 @@ export function createApp<E extends HttpEnv>(options: CreateAppOptions<E>): Open
67
85
  }),
68
86
  );
69
87
  app.use(bodyLimit({ maxSize: options.bodyLimitBytes }));
88
+ if (options.timeouts) app.use(requestTimeout(options.timeouts));
70
89
  app.get("/health", (c) => c.json({ status: "ok" }));
71
90
  app.get("/ready", async (c) => {
72
91
  const result = await runReadinessChecks();
@@ -135,6 +154,8 @@ export function handleErrors<E extends HttpEnv>(
135
154
  ): void {
136
155
  app.onError((err, c) => {
137
156
  if (err instanceof AppError) {
157
+ if (err instanceof TooManyRequestsError)
158
+ c.header("Retry-After", String(err.retryAfterSeconds));
138
159
  const message = options.message ? options.message(err, c as Context<E>) : err.message;
139
160
  return c.json(
140
161
  {
@@ -0,0 +1,44 @@
1
+ import { getConnInfo } from "@hono/node-server/conninfo";
2
+ import type { Context } from "hono";
3
+
4
+ /**
5
+ * The client address from X-Forwarded-For, counting `trustedHops` proxies from the right.
6
+ * Entries to the left of those were sent by the client and can be forged.
7
+ */
8
+ export function trustedClientIp(
9
+ forwardedFor: string | null | undefined,
10
+ trustedHops: number,
11
+ ): string | undefined {
12
+ if (!forwardedFor || trustedHops <= 0) return undefined;
13
+ const hops = forwardedFor
14
+ .split(",")
15
+ .map((entry) => entry.trim())
16
+ .filter(Boolean);
17
+ if (hops.length === 0) return undefined;
18
+ return hops[Math.max(0, hops.length - trustedHops)];
19
+ }
20
+
21
+ /**
22
+ * The caller's address: from X-Forwarded-For when `trustedHops` proxies sit in front (Cloud Run,
23
+ * a load balancer: 1), else the socket's peer. Undefined when neither is known (in-process tests).
24
+ */
25
+ export function clientIp(c: Context, options: { trustedHops?: number } = {}): string | undefined {
26
+ const forwarded = trustedClientIp(c.req.header("x-forwarded-for"), options.trustedHops ?? 0);
27
+ if (forwarded) return forwarded;
28
+ try {
29
+ return getConnInfo(c).remote.address;
30
+ } catch {
31
+ return undefined;
32
+ }
33
+ }
34
+
35
+ /**
36
+ * A copy of `request` whose X-Forwarded-For is only `ip`, for handlers like better-auth that key
37
+ * rate limits on that header and would otherwise trust whatever the client sent.
38
+ */
39
+ export function withClientIp(request: Request, ip: string | undefined): Request {
40
+ const headers = new Headers(request.headers);
41
+ if (ip) headers.set("x-forwarded-for", ip);
42
+ else headers.delete("x-forwarded-for");
43
+ return new Request(request, { headers });
44
+ }
package/src/cursor.ts ADDED
@@ -0,0 +1,43 @@
1
+ import { z } from "@hono/zod-openapi";
2
+ import { AppError } from "./errors.ts";
3
+
4
+ export const cursorQuerySchema = z.object({
5
+ cursor: z.string().max(512).optional(),
6
+ limit: z.coerce.number().int().min(1).max(100).default(20),
7
+ });
8
+
9
+ export type CursorQuery = z.infer<typeof cursorQuerySchema>;
10
+
11
+ export const cursorMetaSchema = z
12
+ .object({ nextCursor: z.string().nullable() })
13
+ .openapi("CursorMeta");
14
+
15
+ export function cursorPageSchema<T extends z.ZodType>(item: T) {
16
+ return z.object({ success: z.literal(true), data: z.array(item), meta: cursorMetaSchema });
17
+ }
18
+
19
+ /** An opaque cursor: base64url JSON of where the next page starts (e.g. `{ createdAt, id }`). */
20
+ export function encodeCursor(position: unknown): string {
21
+ return Buffer.from(JSON.stringify(position)).toString("base64url");
22
+ }
23
+
24
+ /** The position in `cursor`, checked against `schema`; a 400 INVALID_CURSOR when it doesn't parse. */
25
+ export function decodeCursor<T extends z.ZodType>(schema: T, cursor: string): z.infer<T> {
26
+ try {
27
+ return schema.parse(JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")));
28
+ } catch {
29
+ throw new AppError(400, "INVALID_CURSOR", "Invalid cursor", undefined, true);
30
+ }
31
+ }
32
+
33
+ /**
34
+ * One page from rows fetched with `limit + 1`: the extra row only says there's another page.
35
+ * `positionOf` gives the cursor position of the last row on this page.
36
+ */
37
+ export function cursorPage<T>(rows: T[], limit: number, positionOf: (row: T) => unknown) {
38
+ const data = rows.slice(0, limit);
39
+ const last = data.at(-1);
40
+ const nextCursor =
41
+ rows.length > limit && last !== undefined ? encodeCursor(positionOf(last)) : null;
42
+ return { success: true as const, data, meta: { nextCursor } };
43
+ }
package/src/errors.ts CHANGED
@@ -77,3 +77,18 @@ export class ConflictError extends AppError {
77
77
  super(409, "CONFLICT", message ?? "Resource already exists", details, message === undefined);
78
78
  }
79
79
  }
80
+
81
+ export class TooManyRequestsError extends AppError {
82
+ /** seconds until the caller may try again, sent as Retry-After */
83
+ readonly retryAfterSeconds: number;
84
+ constructor(retryAfterSeconds: number) {
85
+ super(429, "RATE_LIMITED", "Too many requests, try again later", undefined, true);
86
+ this.retryAfterSeconds = retryAfterSeconds;
87
+ }
88
+ }
89
+
90
+ export class RequestTimeoutError extends AppError {
91
+ constructor() {
92
+ super(504, "REQUEST_TIMEOUT", "The request took too long", undefined, true);
93
+ }
94
+ }
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /** @tatara/http: plain Hono/OpenAPIHono setup, errors, the response envelope and docs. */
2
2
  export {
3
3
  type CreateAppOptions,
4
+ corsOrigin,
4
5
  createApp,
5
6
  createRouter,
6
7
  defineRoutes,
@@ -11,7 +12,11 @@ export {
11
12
  requestContext,
12
13
  serveOpenApi,
13
14
  } from "./app.ts";
15
+ export { clientIp, trustedClientIp, withClientIp } from "./client-ip.ts";
16
+ export * from "./cursor.ts";
14
17
  export * from "./errors.ts";
18
+ export { type RateLimitCounter, type RateLimitOptions, rateLimit } from "./rate-limit.ts";
15
19
  export * from "./response.ts";
16
20
  export { startServer } from "./server.ts";
21
+ export { requestTimeout, type TimeoutOptions } from "./timeout.ts";
17
22
  export { validationHook } from "./validation.ts";
@@ -0,0 +1,51 @@
1
+ import type { CoreLogger } from "@tatara/core";
2
+ import type { Context, MiddlewareHandler } from "hono";
3
+ import { createMiddleware } from "hono/factory";
4
+ import { TooManyRequestsError } from "./errors.ts";
5
+
6
+ /** What a rate limit counts with; `cache` from `@tatara/cache` is one. */
7
+ export interface RateLimitCounter {
8
+ increment(key: string, options: { ttlMs: number }): Promise<number>;
9
+ }
10
+
11
+ export interface RateLimitOptions {
12
+ counter: RateLimitCounter;
13
+ /** names the limit in its keys, e.g. "sign-in" */
14
+ name: string;
15
+ /** who is counted: an IP, a user id; null skips the limit for this request */
16
+ key: (c: Context) => string | null | undefined | Promise<string | null | undefined>;
17
+ windowMs: number;
18
+ max: number;
19
+ }
20
+
21
+ /**
22
+ * A fixed-window limit: `max` requests per `windowMs` per key, then 429 with Retry-After.
23
+ * A failing counter lets the request through (and logs it) rather than taking the API down.
24
+ */
25
+ export function rateLimit(options: RateLimitOptions): MiddlewareHandler {
26
+ if (!(options.max > 0 && options.windowMs > 0))
27
+ throw new Error("rateLimit needs a positive max and windowMs");
28
+ const retryAfter = Math.ceil(options.windowMs / 1000);
29
+ return createMiddleware(async (c, next) => {
30
+ const who = await options.key(c);
31
+ if (who) {
32
+ let count: number | undefined;
33
+ try {
34
+ count = await options.counter.increment(`rate:${options.name}:${who}`, {
35
+ ttlMs: options.windowMs,
36
+ });
37
+ } catch (err) {
38
+ (c.get("logger") as CoreLogger | undefined)?.warn(
39
+ { err, limit: options.name },
40
+ "rate limit counter unavailable, allowing the request",
41
+ );
42
+ }
43
+ if (count !== undefined) {
44
+ c.header("RateLimit-Limit", String(options.max));
45
+ c.header("RateLimit-Remaining", String(Math.max(0, options.max - count)));
46
+ if (count > options.max) throw new TooManyRequestsError(retryAfter);
47
+ }
48
+ }
49
+ await next();
50
+ });
51
+ }
package/src/response.ts CHANGED
@@ -60,6 +60,7 @@ const ERROR_DESCRIPTIONS = {
60
60
  403: "Forbidden",
61
61
  404: "Not found",
62
62
  409: "Conflict",
63
+ 429: "Too many requests",
63
64
  } as const;
64
65
 
65
66
  type ErrorStatus = keyof typeof ERROR_DESCRIPTIONS;
package/src/server.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { serve } from "@hono/node-server";
1
+ import { type ServerType, serve } from "@hono/node-server";
2
2
  import type { OpenAPIHono } from "@hono/zod-openapi";
3
3
  import { type CoreLogger, handleSignals, onShutdown } from "@tatara/core";
4
4
  import type { HttpEnv } from "./app.ts";
@@ -6,7 +6,13 @@ import type { HttpEnv } from "./app.ts";
6
6
  /** Starts an HTTP app; on shutdown stops accepting connections and drains in-flight requests. */
7
7
  export function startServer<E extends HttpEnv>(
8
8
  app: OpenAPIHono<E>,
9
- options: { port: number; name: string; logger: CoreLogger },
9
+ options: {
10
+ port: number;
11
+ name: string;
12
+ logger: CoreLogger;
13
+ /** runs once the server exists, e.g. to attach a websocket server to it */
14
+ onServer?: (server: ServerType) => void;
15
+ },
10
16
  ) {
11
17
  const { port, name, logger } = options;
12
18
  const server = serve({ fetch: app.fetch, port }, (info) => {
@@ -15,6 +21,7 @@ export function startServer<E extends HttpEnv>(
15
21
  `${name} listening on http://localhost:${info.port}`,
16
22
  );
17
23
  });
24
+ options.onServer?.(server);
18
25
  onShutdown(`${name}-http`, () => new Promise<void>((resolve) => server.close(() => resolve())));
19
26
  handleSignals(logger);
20
27
  return server;
package/src/timeout.ts ADDED
@@ -0,0 +1,39 @@
1
+ import type { MiddlewareHandler } from "hono";
2
+ import { createMiddleware } from "hono/factory";
3
+ import { RequestTimeoutError } from "./errors.ts";
4
+
5
+ export interface TimeoutOptions {
6
+ defaultMs: number;
7
+ /** longer (or shorter) limits by path: "/api/uploads/*" matches everything under it */
8
+ paths?: Record<string, number>;
9
+ }
10
+
11
+ function limitFor(path: string, options: TimeoutOptions): number {
12
+ let best: { length: number; ms: number } | undefined;
13
+ for (const [pattern, ms] of Object.entries(options.paths ?? {})) {
14
+ const prefix = pattern.endsWith("/*") ? pattern.slice(0, -1) : undefined;
15
+ const matches = prefix ? path.startsWith(prefix) || `${path}/` === prefix : path === pattern;
16
+ // the most specific pattern wins
17
+ if (matches && (!best || pattern.length > best.length)) best = { length: pattern.length, ms };
18
+ }
19
+ return best?.ms ?? options.defaultMs;
20
+ }
21
+
22
+ /**
23
+ * Answers 504 REQUEST_TIMEOUT when a handler takes longer than its path's limit. The handler isn't
24
+ * stopped: what it does after the 504 still happens, so give slow paths a longer limit instead.
25
+ */
26
+ export function requestTimeout(options: TimeoutOptions): MiddlewareHandler {
27
+ return createMiddleware(async (c, next) => {
28
+ const ms = limitFor(c.req.path, options);
29
+ let timer: ReturnType<typeof setTimeout> | undefined;
30
+ const expired = new Promise<never>((_, reject) => {
31
+ timer = setTimeout(() => reject(new RequestTimeoutError()), ms);
32
+ });
33
+ try {
34
+ await Promise.race([next(), expired]);
35
+ } finally {
36
+ clearTimeout(timer);
37
+ }
38
+ });
39
+ }