@tatara/http 0.1.1 → 0.1.3

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,25 @@ 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[];
70
+ /**
71
+ * For origins not in `origins`: true allows it. For origins kept in the database, such as a
72
+ * game's web build address; cache the lookup, since it runs on cross-origin requests.
73
+ */
74
+ allowOrigin?: (origin: string) => boolean | Promise<boolean>;
48
75
  exposeHeaders?: string[];
49
76
  allowHeaders?: string[];
50
77
  };
51
78
  bodyLimitBytes: number;
79
+ /** a limit on how long a handler may take, with longer ones for slow paths */
80
+ timeouts?: TimeoutOptions;
52
81
  /** initial values for the app's own variables, e.g. `{ principal: null, language: "en" }` */
53
82
  defaults?: Defaults<E>;
54
83
  }
84
+ /** Matches exact origins and `*.`-patterns, where `*` is exactly one DNS label (tenant subdomains). */
85
+ export declare function corsOrigin(origins: string[]): (origin: string) => string | null;
55
86
  /** A plain OpenAPIHono app with request context, secure headers, CORS, body limit, /health and /ready. */
56
87
  export declare function createApp<E extends HttpEnv>(options: CreateAppOptions<E>): OpenAPIHono<E>;
57
88
  interface RouteDefinition<A extends string = string, E extends HttpEnv = HttpEnv> {
@@ -82,6 +113,79 @@ export declare function handleErrors<E extends HttpEnv>(app: OpenAPIHono<E>, opt
82
113
  message?: (err: AppError, c: Context<E>) => string;
83
114
  }): void;
84
115
  //#endregion
116
+ //#region src/client-ip.d.ts
117
+ /**
118
+ * The client address from X-Forwarded-For, counting `trustedHops` proxies from the right.
119
+ * Entries to the left of those were sent by the client and can be forged.
120
+ */
121
+ export declare function trustedClientIp(forwardedFor: string | null | undefined, trustedHops: number): string | undefined;
122
+ /**
123
+ * The caller's address: from X-Forwarded-For when `trustedHops` proxies sit in front (Cloud Run,
124
+ * a load balancer: 1), else the socket's peer. Undefined when neither is known (in-process tests).
125
+ */
126
+ export declare function clientIp(c: Context, options?: {
127
+ trustedHops?: number;
128
+ }): string | undefined;
129
+ /**
130
+ * A copy of `request` whose X-Forwarded-For is only `ip`, for handlers like better-auth that key
131
+ * rate limits on that header and would otherwise trust whatever the client sent.
132
+ */
133
+ export declare function withClientIp(request: Request, ip: string | undefined): Request;
134
+ //#endregion
135
+ //#region src/cursor.d.ts
136
+ export declare const cursorQuerySchema: z.ZodObject<{
137
+ cursor: z.ZodOptional<z.ZodString>;
138
+ limit: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
139
+ }, z.core.$strip>;
140
+ export type CursorQuery = z.infer<typeof cursorQuerySchema>;
141
+ export declare const cursorMetaSchema: z.ZodObject<{
142
+ nextCursor: z.ZodNullable<z.ZodString>;
143
+ }, z.core.$strip>;
144
+ export declare function cursorPageSchema<T extends z.ZodType>(item: T): z.ZodObject<{
145
+ success: z.ZodLiteral<true>;
146
+ data: z.ZodArray<T>;
147
+ meta: z.ZodObject<{
148
+ nextCursor: z.ZodNullable<z.ZodString>;
149
+ }, z.core.$strip>;
150
+ }, z.core.$strip>;
151
+ /** An opaque cursor: base64url JSON of where the next page starts (e.g. `{ createdAt, id }`). */
152
+ export declare function encodeCursor(position: unknown): string;
153
+ /** The position in `cursor`, checked against `schema`; a 400 INVALID_CURSOR when it doesn't parse. */
154
+ export declare function decodeCursor<T extends z.ZodType>(schema: T, cursor: string): z.infer<T>;
155
+ /**
156
+ * One page from rows fetched with `limit + 1`: the extra row only says there's another page.
157
+ * `positionOf` gives the cursor position of the last row on this page.
158
+ */
159
+ export declare function cursorPage<T>(rows: T[], limit: number, positionOf: (row: T) => unknown): {
160
+ success: true;
161
+ data: T[];
162
+ meta: {
163
+ nextCursor: string | null;
164
+ };
165
+ };
166
+ //#endregion
167
+ //#region src/rate-limit.d.ts
168
+ /** What a rate limit counts with; `cache` from `@tatara/cache` is one. */
169
+ interface RateLimitCounter {
170
+ increment(key: string, options: {
171
+ ttlMs: number;
172
+ }): Promise<number>;
173
+ }
174
+ interface RateLimitOptions {
175
+ counter: RateLimitCounter;
176
+ /** names the limit in its keys, e.g. "sign-in" */
177
+ name: string;
178
+ /** who is counted: an IP, a user id; null skips the limit for this request */
179
+ key: (c: Context) => string | null | undefined | Promise<string | null | undefined>;
180
+ windowMs: number;
181
+ max: number;
182
+ }
183
+ /**
184
+ * A fixed-window limit: `max` requests per `windowMs` per key, then 429 with Retry-After.
185
+ * A failing counter lets the request through (and logs it) rather than taking the API down.
186
+ */
187
+ export declare function rateLimit(options: RateLimitOptions): MiddlewareHandler;
188
+ //#endregion
85
189
  //#region src/response.d.ts
86
190
  export declare const errorSchema: z.ZodObject<{
87
191
  success: z.ZodLiteral<false>;
@@ -152,6 +256,7 @@ declare const ERROR_DESCRIPTIONS: {
152
256
  readonly 403: "Forbidden";
153
257
  readonly 404: "Not found";
154
258
  readonly 409: "Conflict";
259
+ readonly 429: "Too many requests";
155
260
  };
156
261
  type ErrorStatus = keyof typeof ERROR_DESCRIPTIONS;
157
262
  /** OpenAPI error responses for the given statuses, all using the shared error envelope. */
@@ -163,9 +268,11 @@ export declare function startServer<E extends HttpEnv>(app: OpenAPIHono<E>, opti
163
268
  port: number;
164
269
  name: string;
165
270
  logger: CoreLogger;
166
- }): import("@hono/node-server").ServerType;
271
+ /** runs once the server exists, e.g. to attach a websocket server to it */
272
+ onServer?: (server: ServerType) => void;
273
+ }): ServerType;
167
274
  //#endregion
168
275
  //#region src/validation.d.ts
169
276
  export declare const validationHook: Hook<any, any, any, any>;
170
277
  //#endregion
171
- export type { CreateAppOptions, HttpEnv, RouteDefinition };
278
+ 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,26 @@ 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
+ }
148
+ function allowedOrigin(listed, check) {
149
+ return async (origin) => listed(origin) ?? (origin && check && await check(origin) ? origin : null);
150
+ }
93
151
  /** A plain OpenAPIHono app with request context, secure headers, CORS, body limit, /health and /ready. */
94
152
  function createApp(options) {
95
153
  const app = new OpenAPIHono({ defaultHook: validationHook });
96
154
  app.use(requestContext(options.logger, options.defaults));
97
155
  app.use(secureHeaders());
98
156
  app.use(cors({
99
- origin: options.cors.origins,
157
+ origin: allowedOrigin(corsOrigin(options.cors.origins), options.cors.allowOrigin),
100
158
  credentials: true,
101
159
  allowHeaders: options.cors.allowHeaders ?? [
102
160
  "Content-Type",
@@ -107,6 +165,7 @@ function createApp(options) {
107
165
  exposeHeaders: options.cors.exposeHeaders ?? ["X-Request-Id"]
108
166
  }));
109
167
  app.use(bodyLimit({ maxSize: options.bodyLimitBytes }));
168
+ if (options.timeouts) app.use(requestTimeout(options.timeouts));
110
169
  app.get("/health", (c) => c.json({ status: "ok" }));
111
170
  app.get("/ready", async (c) => {
112
171
  const result = await runReadinessChecks();
@@ -149,6 +208,7 @@ function serveOpenApi(app, options) {
149
208
  function handleErrors(app, options = {}) {
150
209
  app.onError((err, c) => {
151
210
  if (err instanceof AppError) {
211
+ if (err instanceof TooManyRequestsError) c.header("Retry-After", String(err.retryAfterSeconds));
152
212
  const message = options.message ? options.message(err, c) : err.message;
153
213
  return c.json({
154
214
  success: false,
@@ -176,6 +236,110 @@ function handleErrors(app, options = {}) {
176
236
  }, 404));
177
237
  }
178
238
  //#endregion
239
+ //#region src/client-ip.ts
240
+ /**
241
+ * The client address from X-Forwarded-For, counting `trustedHops` proxies from the right.
242
+ * Entries to the left of those were sent by the client and can be forged.
243
+ */
244
+ function trustedClientIp(forwardedFor, trustedHops) {
245
+ if (!forwardedFor || trustedHops <= 0) return void 0;
246
+ const hops = forwardedFor.split(",").map((entry) => entry.trim()).filter(Boolean);
247
+ if (hops.length === 0) return void 0;
248
+ return hops[Math.max(0, hops.length - trustedHops)];
249
+ }
250
+ /**
251
+ * The caller's address: from X-Forwarded-For when `trustedHops` proxies sit in front (Cloud Run,
252
+ * a load balancer: 1), else the socket's peer. Undefined when neither is known (in-process tests).
253
+ */
254
+ function clientIp(c, options = {}) {
255
+ const forwarded = trustedClientIp(c.req.header("x-forwarded-for"), options.trustedHops ?? 0);
256
+ if (forwarded) return forwarded;
257
+ try {
258
+ return getConnInfo(c).remote.address;
259
+ } catch {
260
+ return;
261
+ }
262
+ }
263
+ /**
264
+ * A copy of `request` whose X-Forwarded-For is only `ip`, for handlers like better-auth that key
265
+ * rate limits on that header and would otherwise trust whatever the client sent.
266
+ */
267
+ function withClientIp(request, ip) {
268
+ const headers = new Headers(request.headers);
269
+ if (ip) headers.set("x-forwarded-for", ip);
270
+ else headers.delete("x-forwarded-for");
271
+ return new Request(request, { headers });
272
+ }
273
+ //#endregion
274
+ //#region src/cursor.ts
275
+ const cursorQuerySchema = z.object({
276
+ cursor: z.string().max(512).optional(),
277
+ limit: z.coerce.number().int().min(1).max(100).default(20)
278
+ });
279
+ const cursorMetaSchema = z.object({ nextCursor: z.string().nullable() }).openapi("CursorMeta");
280
+ function cursorPageSchema(item) {
281
+ return z.object({
282
+ success: z.literal(true),
283
+ data: z.array(item),
284
+ meta: cursorMetaSchema
285
+ });
286
+ }
287
+ /** An opaque cursor: base64url JSON of where the next page starts (e.g. `{ createdAt, id }`). */
288
+ function encodeCursor(position) {
289
+ return Buffer.from(JSON.stringify(position)).toString("base64url");
290
+ }
291
+ /** The position in `cursor`, checked against `schema`; a 400 INVALID_CURSOR when it doesn't parse. */
292
+ function decodeCursor(schema, cursor) {
293
+ try {
294
+ return schema.parse(JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")));
295
+ } catch {
296
+ throw new AppError(400, "INVALID_CURSOR", "Invalid cursor", void 0, true);
297
+ }
298
+ }
299
+ /**
300
+ * One page from rows fetched with `limit + 1`: the extra row only says there's another page.
301
+ * `positionOf` gives the cursor position of the last row on this page.
302
+ */
303
+ function cursorPage(rows, limit, positionOf) {
304
+ const data = rows.slice(0, limit);
305
+ const last = data.at(-1);
306
+ return {
307
+ success: true,
308
+ data,
309
+ meta: { nextCursor: rows.length > limit && last !== void 0 ? encodeCursor(positionOf(last)) : null }
310
+ };
311
+ }
312
+ //#endregion
313
+ //#region src/rate-limit.ts
314
+ /**
315
+ * A fixed-window limit: `max` requests per `windowMs` per key, then 429 with Retry-After.
316
+ * A failing counter lets the request through (and logs it) rather than taking the API down.
317
+ */
318
+ function rateLimit(options) {
319
+ if (!(options.max > 0 && options.windowMs > 0)) throw new Error("rateLimit needs a positive max and windowMs");
320
+ const retryAfter = Math.ceil(options.windowMs / 1e3);
321
+ return createMiddleware(async (c, next) => {
322
+ const who = await options.key(c);
323
+ if (who) {
324
+ let count;
325
+ try {
326
+ count = await options.counter.increment(`rate:${options.name}:${who}`, { ttlMs: options.windowMs });
327
+ } catch (err) {
328
+ c.get("logger")?.warn({
329
+ err,
330
+ limit: options.name
331
+ }, "rate limit counter unavailable, allowing the request");
332
+ }
333
+ if (count !== void 0) {
334
+ c.header("RateLimit-Limit", String(options.max));
335
+ c.header("RateLimit-Remaining", String(Math.max(0, options.max - count)));
336
+ if (count > options.max) throw new TooManyRequestsError(retryAfter);
337
+ }
338
+ }
339
+ await next();
340
+ });
341
+ }
342
+ //#endregion
179
343
  //#region src/response.ts
180
344
  const errorSchema = z.object({
181
345
  success: z.literal(false),
@@ -242,7 +406,8 @@ const ERROR_DESCRIPTIONS = {
242
406
  401: "Authentication required",
243
407
  403: "Forbidden",
244
408
  404: "Not found",
245
- 409: "Conflict"
409
+ 409: "Conflict",
410
+ 429: "Too many requests"
246
411
  };
247
412
  /** OpenAPI error responses for the given statuses, all using the shared error envelope. */
248
413
  function errorResponses(...statuses) {
@@ -262,9 +427,10 @@ function startServer(app, options) {
262
427
  app: name
263
428
  }, `${name} listening on http://localhost:${info.port}`);
264
429
  });
430
+ options.onServer?.(server);
265
431
  onShutdown(`${name}-http`, () => new Promise((resolve) => server.close(() => resolve())));
266
432
  handleSignals(logger);
267
433
  return server;
268
434
  }
269
435
  //#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 };
436
+ 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.3",
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.3",
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,46 @@ export function requestContext<E extends HttpEnv>(logger: CoreLogger, defaults:
42
43
 
43
44
  export interface CreateAppOptions<E extends HttpEnv> {
44
45
  logger: CoreLogger;
45
- cors: { origins: string[]; exposeHeaders?: string[]; allowHeaders?: string[] };
46
+ /** origins are exact (`https://app.example.com`) or one-label patterns (`https://*.example.com`) */
47
+ cors: {
48
+ origins: string[];
49
+ /**
50
+ * For origins not in `origins`: true allows it. For origins kept in the database, such as a
51
+ * game's web build address; cache the lookup, since it runs on cross-origin requests.
52
+ */
53
+ allowOrigin?: (origin: string) => boolean | Promise<boolean>;
54
+ exposeHeaders?: string[];
55
+ allowHeaders?: string[];
56
+ };
46
57
  bodyLimitBytes: number;
58
+ /** a limit on how long a handler may take, with longer ones for slow paths */
59
+ timeouts?: TimeoutOptions;
47
60
  /** initial values for the app's own variables, e.g. `{ principal: null, language: "en" }` */
48
61
  defaults?: Defaults<E>;
49
62
  }
50
63
 
64
+ /** Matches exact origins and `*.`-patterns, where `*` is exactly one DNS label (tenant subdomains). */
65
+ export function corsOrigin(origins: string[]): (origin: string) => string | null {
66
+ const exact = new Set(origins.filter((o) => !o.includes("*")));
67
+ const patterns = origins
68
+ .filter((o) => o.includes("*"))
69
+ .map((o) => {
70
+ if (!/^https?:\/\/\*\.[^*]+$/.test(o))
71
+ throw new Error(`CORS origin pattern "${o}" must look like https://*.example.com`);
72
+ const escaped = o.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace("*", "[a-z0-9-]+");
73
+ return new RegExp(`^${escaped}$`, "i");
74
+ });
75
+ return (origin) => (exact.has(origin) || patterns.some((p) => p.test(origin)) ? origin : null);
76
+ }
77
+
78
+ function allowedOrigin(
79
+ listed: (origin: string) => string | null,
80
+ check?: (origin: string) => boolean | Promise<boolean>,
81
+ ): (origin: string) => Promise<string | null> {
82
+ return async (origin) =>
83
+ listed(origin) ?? (origin && check && (await check(origin)) ? origin : null);
84
+ }
85
+
51
86
  /** A plain OpenAPIHono app with request context, secure headers, CORS, body limit, /health and /ready. */
52
87
  export function createApp<E extends HttpEnv>(options: CreateAppOptions<E>): OpenAPIHono<E> {
53
88
  const app = new OpenAPIHono<E>({ defaultHook: validationHook });
@@ -55,7 +90,7 @@ export function createApp<E extends HttpEnv>(options: CreateAppOptions<E>): Open
55
90
  app.use(secureHeaders());
56
91
  app.use(
57
92
  cors({
58
- origin: options.cors.origins,
93
+ origin: allowedOrigin(corsOrigin(options.cors.origins), options.cors.allowOrigin),
59
94
  credentials: true,
60
95
  allowHeaders: options.cors.allowHeaders ?? [
61
96
  "Content-Type",
@@ -67,6 +102,7 @@ export function createApp<E extends HttpEnv>(options: CreateAppOptions<E>): Open
67
102
  }),
68
103
  );
69
104
  app.use(bodyLimit({ maxSize: options.bodyLimitBytes }));
105
+ if (options.timeouts) app.use(requestTimeout(options.timeouts));
70
106
  app.get("/health", (c) => c.json({ status: "ok" }));
71
107
  app.get("/ready", async (c) => {
72
108
  const result = await runReadinessChecks();
@@ -135,6 +171,8 @@ export function handleErrors<E extends HttpEnv>(
135
171
  ): void {
136
172
  app.onError((err, c) => {
137
173
  if (err instanceof AppError) {
174
+ if (err instanceof TooManyRequestsError)
175
+ c.header("Retry-After", String(err.retryAfterSeconds));
138
176
  const message = options.message ? options.message(err, c as Context<E>) : err.message;
139
177
  return c.json(
140
178
  {
@@ -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
+ }