@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 +109 -2
- package/dist/index.mjs +169 -3
- package/package.json +3 -3
- package/src/app.ts +41 -3
- 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,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
|
-
|
|
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.
|
|
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.
|
|
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.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
|
-
|
|
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
|
{
|
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
|
+
}
|