@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 +104 -2
- package/dist/index.mjs +166 -3
- package/package.json +3 -3
- package/src/app.ts +23 -2
- package/src/client-ip.ts +44 -0
- package/src/cursor.ts +43 -0
- package/src/errors.ts +15 -0
- package/src/index.ts +5 -0
- package/src/rate-limit.ts +51 -0
- package/src/response.ts +1 -0
- package/src/server.ts +9 -2
- package/src/timeout.ts +39 -0
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
{
|
package/src/client-ip.ts
ADDED
|
@@ -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
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: {
|
|
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
|
+
}
|