@workkit/hono 0.0.1
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/README.md +87 -0
- package/dist/index.cjs +209 -0
- package/dist/index.d.cts +186 -0
- package/dist/index.d.ts +186 -0
- package/dist/index.js +176 -0
- package/dist/index.js.map +14 -0
- package/package.json +65 -0
package/README.md
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# @workkit/hono
|
|
2
|
+
|
|
3
|
+
> Hono middleware for env validation, error handling, rate limiting, and caching
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/@workkit/hono)
|
|
6
|
+
[](https://bundlephobia.com/package/@workkit/hono)
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
bun add @workkit/hono hono
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Usage
|
|
15
|
+
|
|
16
|
+
### Before (manual Hono setup)
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import { Hono } from "hono"
|
|
20
|
+
|
|
21
|
+
const app = new Hono()
|
|
22
|
+
|
|
23
|
+
app.use("*", async (c, next) => {
|
|
24
|
+
// Manual env validation on every request
|
|
25
|
+
if (!c.env.API_KEY) return c.text("Missing API_KEY", 500)
|
|
26
|
+
await next()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
app.onError((err, c) => {
|
|
30
|
+
// Generic error handling — lose structured error info
|
|
31
|
+
return c.json({ error: err.message }, 500)
|
|
32
|
+
})
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### After (workkit hono)
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
import { Hono } from "hono"
|
|
39
|
+
import { workkit, workkitErrorHandler, rateLimit, cacheResponse } from "@workkit/hono"
|
|
40
|
+
import { z } from "zod"
|
|
41
|
+
|
|
42
|
+
const app = new Hono()
|
|
43
|
+
|
|
44
|
+
// Validate env on first request — typed and cached
|
|
45
|
+
app.use(workkit({ env: { API_KEY: z.string().min(1), DB: z.any() } }))
|
|
46
|
+
|
|
47
|
+
// Structured error handling — WorkkitErrors become proper JSON responses
|
|
48
|
+
app.onError(workkitErrorHandler())
|
|
49
|
+
|
|
50
|
+
// KV-backed rate limiting
|
|
51
|
+
app.use("/api/*", rateLimit({ limit: 100, window: "1m" }))
|
|
52
|
+
|
|
53
|
+
// Response caching
|
|
54
|
+
app.use("/api/public/*", cacheResponse({ ttl: 300 }))
|
|
55
|
+
|
|
56
|
+
app.get("/", (c) => {
|
|
57
|
+
const env = c.get("workkit:env") // fully typed
|
|
58
|
+
return c.json({ key: env.API_KEY })
|
|
59
|
+
})
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## API
|
|
63
|
+
|
|
64
|
+
### Middleware
|
|
65
|
+
|
|
66
|
+
- **`workkit(options)`** — Validate environment bindings. Stores typed env in `c.get("workkit:env")`.
|
|
67
|
+
|
|
68
|
+
### Error Handling
|
|
69
|
+
|
|
70
|
+
- **`workkitErrorHandler(options?)`** — Convert `WorkkitError` instances to structured JSON responses with proper status codes.
|
|
71
|
+
|
|
72
|
+
### Rate Limiting
|
|
73
|
+
|
|
74
|
+
- **`rateLimit(options)`** — KV-backed rate limiting middleware. Options: `limit`, `window`, `keyFn?`
|
|
75
|
+
- **`fixedWindow(options)`** — Fixed window rate limiter (lower-level)
|
|
76
|
+
|
|
77
|
+
### Caching
|
|
78
|
+
|
|
79
|
+
- **`cacheResponse(options)`** — Cache responses using the Cache API. Options: `ttl`, `vary?`
|
|
80
|
+
|
|
81
|
+
### Helpers
|
|
82
|
+
|
|
83
|
+
- **`getEnv(c)`** — Get validated env from Hono context (shorthand for `c.get("workkit:env")`)
|
|
84
|
+
|
|
85
|
+
## License
|
|
86
|
+
|
|
87
|
+
MIT
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
var import_node_module = require("node:module");
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __moduleCache = /* @__PURE__ */ new WeakMap;
|
|
7
|
+
var __toCommonJS = (from) => {
|
|
8
|
+
var entry = __moduleCache.get(from), desc;
|
|
9
|
+
if (entry)
|
|
10
|
+
return entry;
|
|
11
|
+
entry = __defProp({}, "__esModule", { value: true });
|
|
12
|
+
if (from && typeof from === "object" || typeof from === "function")
|
|
13
|
+
__getOwnPropNames(from).map((key) => !__hasOwnProp.call(entry, key) && __defProp(entry, key, {
|
|
14
|
+
get: () => from[key],
|
|
15
|
+
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
16
|
+
}));
|
|
17
|
+
__moduleCache.set(from, entry);
|
|
18
|
+
return entry;
|
|
19
|
+
};
|
|
20
|
+
var __export = (target, all) => {
|
|
21
|
+
for (var name in all)
|
|
22
|
+
__defProp(target, name, {
|
|
23
|
+
get: all[name],
|
|
24
|
+
enumerable: true,
|
|
25
|
+
configurable: true,
|
|
26
|
+
set: (newValue) => all[name] = () => newValue
|
|
27
|
+
});
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var exports_src = {};
|
|
32
|
+
__export(exports_src, {
|
|
33
|
+
workkitErrorHandler: () => workkitErrorHandler,
|
|
34
|
+
workkit: () => workkit,
|
|
35
|
+
rateLimit: () => rateLimit,
|
|
36
|
+
parseDuration: () => parseDuration,
|
|
37
|
+
getEnv: () => getEnv,
|
|
38
|
+
fixedWindow: () => fixedWindow,
|
|
39
|
+
cacheResponse: () => cacheResponse
|
|
40
|
+
});
|
|
41
|
+
module.exports = __toCommonJS(exports_src);
|
|
42
|
+
|
|
43
|
+
// src/middleware.ts
|
|
44
|
+
var import_env = require("@workkit/env");
|
|
45
|
+
function workkit(options) {
|
|
46
|
+
let cachedEnv = null;
|
|
47
|
+
return async (c, next) => {
|
|
48
|
+
if (!cachedEnv) {
|
|
49
|
+
const rawEnv = c.env;
|
|
50
|
+
cachedEnv = await import_env.parseEnv(rawEnv, options.env);
|
|
51
|
+
}
|
|
52
|
+
c.set("workkit:env", cachedEnv);
|
|
53
|
+
c.set("workkit:envValidated", true);
|
|
54
|
+
await next();
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
// src/error-handler.ts
|
|
58
|
+
var import_errors = require("@workkit/errors");
|
|
59
|
+
function workkitErrorHandler(options = {}) {
|
|
60
|
+
const { includeStack = false, onError } = options;
|
|
61
|
+
return async (err, c) => {
|
|
62
|
+
if (onError) {
|
|
63
|
+
try {
|
|
64
|
+
await onError(err, c);
|
|
65
|
+
} catch {}
|
|
66
|
+
}
|
|
67
|
+
if (import_errors.isWorkkitError(err)) {
|
|
68
|
+
return workkitErrorToResponse(err, includeStack);
|
|
69
|
+
}
|
|
70
|
+
const wrapped = new import_errors.InternalError(err instanceof Error ? err.message : "An unexpected error occurred", { cause: err });
|
|
71
|
+
return workkitErrorToResponse(wrapped, includeStack);
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function workkitErrorToResponse(error, includeStack) {
|
|
75
|
+
const body = {
|
|
76
|
+
error: {
|
|
77
|
+
code: error.code,
|
|
78
|
+
message: error.message,
|
|
79
|
+
statusCode: error.statusCode
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
if ("issues" in error && Array.isArray(error.issues)) {
|
|
83
|
+
body.error.issues = error.issues;
|
|
84
|
+
}
|
|
85
|
+
if (includeStack && error.stack) {
|
|
86
|
+
body.error.stack = error.stack;
|
|
87
|
+
}
|
|
88
|
+
const headers = {
|
|
89
|
+
"Content-Type": "application/json"
|
|
90
|
+
};
|
|
91
|
+
if (error instanceof import_errors.RateLimitError && error.retryAfterMs) {
|
|
92
|
+
headers["Retry-After"] = String(Math.ceil(error.retryAfterMs / 1000));
|
|
93
|
+
}
|
|
94
|
+
return new Response(JSON.stringify(body), {
|
|
95
|
+
status: error.statusCode,
|
|
96
|
+
headers
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
// src/rate-limit.ts
|
|
100
|
+
var import_errors2 = require("@workkit/errors");
|
|
101
|
+
function rateLimit(options) {
|
|
102
|
+
const { limiter, keyFn, onRateLimited } = options;
|
|
103
|
+
return async (c, next) => {
|
|
104
|
+
const key = await keyFn(c);
|
|
105
|
+
const result = await limiter.check(key);
|
|
106
|
+
c.header("X-RateLimit-Limit", String(result.remaining + (result.allowed ? 0 : 1)));
|
|
107
|
+
c.header("X-RateLimit-Remaining", String(result.remaining));
|
|
108
|
+
c.header("X-RateLimit-Reset", String(Math.ceil(result.resetAt / 1000)));
|
|
109
|
+
if (!result.allowed) {
|
|
110
|
+
if (onRateLimited) {
|
|
111
|
+
return onRateLimited(c, result);
|
|
112
|
+
}
|
|
113
|
+
const retryAfterMs = result.resetAt - Date.now();
|
|
114
|
+
throw new import_errors2.RateLimitError("Rate limit exceeded", retryAfterMs > 0 ? retryAfterMs : undefined);
|
|
115
|
+
}
|
|
116
|
+
await next();
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function parseDuration(duration) {
|
|
120
|
+
const match = duration.match(/^(\d+)(s|m|h|d)$/);
|
|
121
|
+
if (!match) {
|
|
122
|
+
throw new Error(`Invalid duration format: "${duration}". Use e.g. '1m', '5m', '1h', '1d'.`);
|
|
123
|
+
}
|
|
124
|
+
const value = Number.parseInt(match[1], 10);
|
|
125
|
+
const unit = match[2];
|
|
126
|
+
switch (unit) {
|
|
127
|
+
case "s":
|
|
128
|
+
return value * 1000;
|
|
129
|
+
case "m":
|
|
130
|
+
return value * 60 * 1000;
|
|
131
|
+
case "h":
|
|
132
|
+
return value * 60 * 60 * 1000;
|
|
133
|
+
case "d":
|
|
134
|
+
return value * 24 * 60 * 60 * 1000;
|
|
135
|
+
default:
|
|
136
|
+
throw new Error(`Unknown duration unit: "${unit}"`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
function fixedWindow(options) {
|
|
140
|
+
const { namespace, limit, window: windowStr, prefix = "rl:" } = options;
|
|
141
|
+
const windowMs = parseDuration(windowStr);
|
|
142
|
+
return {
|
|
143
|
+
async check(key) {
|
|
144
|
+
const now = Date.now();
|
|
145
|
+
const windowStart = Math.floor(now / windowMs) * windowMs;
|
|
146
|
+
const resetAt = windowStart + windowMs;
|
|
147
|
+
const kvKey = `${prefix}${key}:${windowStart}`;
|
|
148
|
+
const current = await namespace.get(kvKey);
|
|
149
|
+
const count = current ? Number.parseInt(current, 10) : 0;
|
|
150
|
+
if (count >= limit) {
|
|
151
|
+
return { allowed: false, remaining: 0, resetAt };
|
|
152
|
+
}
|
|
153
|
+
const ttlSeconds = Math.ceil(windowMs / 1000);
|
|
154
|
+
await namespace.put(kvKey, String(count + 1), {
|
|
155
|
+
expirationTtl: ttlSeconds
|
|
156
|
+
});
|
|
157
|
+
return { allowed: true, remaining: limit - count - 1, resetAt };
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
// src/cache.ts
|
|
162
|
+
function cacheResponse(options) {
|
|
163
|
+
const { ttl, keyFn, methods = ["GET"] } = options;
|
|
164
|
+
return async (c, next) => {
|
|
165
|
+
if (!methods.includes(c.req.method)) {
|
|
166
|
+
await next();
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const cacheKey = keyFn ? keyFn(c) : c.req.url;
|
|
170
|
+
const cacheRequest = new Request(cacheKey);
|
|
171
|
+
const cache = options.cache ?? (typeof caches !== "undefined" ? caches.default : null);
|
|
172
|
+
if (!cache) {
|
|
173
|
+
await next();
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const cached = await cache.match(cacheRequest);
|
|
177
|
+
if (cached) {
|
|
178
|
+
return cached;
|
|
179
|
+
}
|
|
180
|
+
await next();
|
|
181
|
+
const response = c.res;
|
|
182
|
+
if (response.status >= 200 && response.status < 300) {
|
|
183
|
+
const cloned = response.clone();
|
|
184
|
+
const cachedResponse = new Response(cloned.body, {
|
|
185
|
+
status: cloned.status,
|
|
186
|
+
statusText: cloned.statusText,
|
|
187
|
+
headers: new Headers(cloned.headers)
|
|
188
|
+
});
|
|
189
|
+
cachedResponse.headers.set("Cache-Control", `s-maxage=${ttl}`);
|
|
190
|
+
const putPromise = cache.put(cacheRequest, cachedResponse);
|
|
191
|
+
try {
|
|
192
|
+
c.executionCtx.waitUntil(putPromise);
|
|
193
|
+
} catch {
|
|
194
|
+
await putPromise;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
// src/helpers.ts
|
|
200
|
+
function getEnv(c) {
|
|
201
|
+
const validated = c.get("workkit:envValidated");
|
|
202
|
+
if (!validated) {
|
|
203
|
+
throw new Error("workkit:env is not available. Did you forget to add the workkit() middleware?");
|
|
204
|
+
}
|
|
205
|
+
return c.get("workkit:env");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
//# debugId=4C2D147FF9900DA464756E2164756E21
|
|
209
|
+
//# sourceMappingURL=index.js.map
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { EnvSchema as EnvSchema2 } from "@workkit/env";
|
|
2
|
+
import { MiddlewareHandler } from "hono";
|
|
3
|
+
import { EnvSchema, InferEnv } from "@workkit/env";
|
|
4
|
+
import { Context, Env } from "hono";
|
|
5
|
+
/**
|
|
6
|
+
* Options for the workkit() middleware.
|
|
7
|
+
*/
|
|
8
|
+
interface WorkkitOptions<T extends EnvSchema> {
|
|
9
|
+
/** Environment schema to validate against on first request */
|
|
10
|
+
env: T;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Options for the workkitErrorHandler.
|
|
14
|
+
*/
|
|
15
|
+
interface ErrorHandlerOptions {
|
|
16
|
+
/** Include stack trace in error response (never in production) */
|
|
17
|
+
includeStack?: boolean;
|
|
18
|
+
/** Custom error callback for logging/reporting */
|
|
19
|
+
onError?: (err: Error, c: Context) => void | Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* A rate limiter instance that checks whether a key is allowed.
|
|
23
|
+
*/
|
|
24
|
+
interface RateLimiter {
|
|
25
|
+
/** Check if the key is allowed. Returns { allowed, remaining, resetAt } */
|
|
26
|
+
check(key: string): Promise<RateLimitResult>;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Result of a rate limit check.
|
|
30
|
+
*/
|
|
31
|
+
interface RateLimitResult {
|
|
32
|
+
/** Whether the request is allowed */
|
|
33
|
+
allowed: boolean;
|
|
34
|
+
/** Remaining requests in the current window */
|
|
35
|
+
remaining: number;
|
|
36
|
+
/** When the window resets (ms since epoch) */
|
|
37
|
+
resetAt: number;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Options for the rateLimit middleware.
|
|
41
|
+
*/
|
|
42
|
+
interface RateLimitOptions {
|
|
43
|
+
/** The rate limiter implementation */
|
|
44
|
+
limiter: RateLimiter;
|
|
45
|
+
/** Function to extract the rate limit key from context (e.g., IP address) */
|
|
46
|
+
keyFn: (c: Context) => string | Promise<string>;
|
|
47
|
+
/** Custom response when rate limited (optional) */
|
|
48
|
+
onRateLimited?: (c: Context, result: RateLimitResult) => Response | Promise<Response>;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Options for fixed-window rate limiter.
|
|
52
|
+
*/
|
|
53
|
+
interface FixedWindowOptions {
|
|
54
|
+
/** KV namespace for storing counters */
|
|
55
|
+
namespace: KVNamespace;
|
|
56
|
+
/** Maximum requests per window */
|
|
57
|
+
limit: number;
|
|
58
|
+
/** Window duration — e.g. '1m', '5m', '1h', '1d' */
|
|
59
|
+
window: string;
|
|
60
|
+
/** Optional prefix for KV keys */
|
|
61
|
+
prefix?: string;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Options for the cacheResponse middleware.
|
|
65
|
+
*/
|
|
66
|
+
interface CacheOptions {
|
|
67
|
+
/** Cache TTL in seconds */
|
|
68
|
+
ttl: number;
|
|
69
|
+
/** Function to generate the cache key (defaults to request URL) */
|
|
70
|
+
keyFn?: (c: Context) => string;
|
|
71
|
+
/** Cache API instance (defaults to caches.default) */
|
|
72
|
+
cache?: Cache;
|
|
73
|
+
/** HTTP methods to cache (defaults to ['GET']) */
|
|
74
|
+
methods?: string[];
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Hono environment type with workkit context variables.
|
|
78
|
+
*/
|
|
79
|
+
interface WorkkitEnv<T extends EnvSchema = EnvSchema> extends Env {
|
|
80
|
+
Variables: {
|
|
81
|
+
"workkit:env": InferEnv<T>;
|
|
82
|
+
"workkit:envValidated": boolean;
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Main workkit middleware — validates environment bindings on first request
|
|
87
|
+
* and stores the parsed, typed env in Hono's context.
|
|
88
|
+
*
|
|
89
|
+
* Validation runs once (on the first request) and the result is cached
|
|
90
|
+
* for subsequent requests within the same Worker invocation.
|
|
91
|
+
*
|
|
92
|
+
* @example
|
|
93
|
+
* ```ts
|
|
94
|
+
* const app = new Hono()
|
|
95
|
+
* app.use(workkit({ env: { API_KEY: z.string().min(1) } }))
|
|
96
|
+
* app.get('/', (c) => {
|
|
97
|
+
* const env = c.get('workkit:env') // typed
|
|
98
|
+
* })
|
|
99
|
+
* ```
|
|
100
|
+
*/
|
|
101
|
+
declare function workkit<T extends EnvSchema2>(options: WorkkitOptions<T>): MiddlewareHandler<WorkkitEnv<T>>;
|
|
102
|
+
import { ErrorHandler } from "hono";
|
|
103
|
+
/**
|
|
104
|
+
* Hono error handler that converts WorkkitErrors to proper HTTP responses.
|
|
105
|
+
*
|
|
106
|
+
* - WorkkitError instances → structured JSON with their status code
|
|
107
|
+
* - RateLimitError → includes Retry-After header
|
|
108
|
+
* - ValidationError → includes issues array
|
|
109
|
+
* - Unknown errors → 500 Internal Server Error
|
|
110
|
+
*
|
|
111
|
+
* @example
|
|
112
|
+
* ```ts
|
|
113
|
+
* app.onError(workkitErrorHandler({
|
|
114
|
+
* includeStack: false,
|
|
115
|
+
* onError: (err, c) => console.error(err),
|
|
116
|
+
* }))
|
|
117
|
+
* ```
|
|
118
|
+
*/
|
|
119
|
+
declare function workkitErrorHandler(options?: ErrorHandlerOptions): ErrorHandler;
|
|
120
|
+
import { MiddlewareHandler as MiddlewareHandler2 } from "hono";
|
|
121
|
+
/**
|
|
122
|
+
* Rate limit middleware for Hono.
|
|
123
|
+
*
|
|
124
|
+
* @example
|
|
125
|
+
* ```ts
|
|
126
|
+
* app.use('/api/*', rateLimit({
|
|
127
|
+
* limiter: fixedWindow({ namespace: env.KV, limit: 100, window: '1m' }),
|
|
128
|
+
* keyFn: (c) => c.req.header('CF-Connecting-IP') ?? 'unknown',
|
|
129
|
+
* }))
|
|
130
|
+
* ```
|
|
131
|
+
*/
|
|
132
|
+
declare function rateLimit(options: RateLimitOptions): MiddlewareHandler2;
|
|
133
|
+
/**
|
|
134
|
+
* Parse a duration string like '1m', '5m', '1h', '1d' into milliseconds.
|
|
135
|
+
*/
|
|
136
|
+
declare function parseDuration(duration: string): number;
|
|
137
|
+
/**
|
|
138
|
+
* Creates a fixed-window rate limiter backed by KV.
|
|
139
|
+
*
|
|
140
|
+
* Each window is stored as a KV key with an expiration TTL.
|
|
141
|
+
* Uses KV's eventual consistency — suitable for soft rate limiting,
|
|
142
|
+
* not cryptographic precision.
|
|
143
|
+
*
|
|
144
|
+
* @example
|
|
145
|
+
* ```ts
|
|
146
|
+
* const limiter = fixedWindow({
|
|
147
|
+
* namespace: env.RATE_LIMIT_KV,
|
|
148
|
+
* limit: 100,
|
|
149
|
+
* window: '1m',
|
|
150
|
+
* })
|
|
151
|
+
* ```
|
|
152
|
+
*/
|
|
153
|
+
declare function fixedWindow(options: FixedWindowOptions): RateLimiter;
|
|
154
|
+
import { MiddlewareHandler as MiddlewareHandler3 } from "hono";
|
|
155
|
+
/**
|
|
156
|
+
* Cache middleware for Hono — caches responses using the Cache API.
|
|
157
|
+
*
|
|
158
|
+
* Only caches successful (2xx) responses. Serves cached responses on cache hit.
|
|
159
|
+
* Uses Cloudflare's Cache API by default.
|
|
160
|
+
*
|
|
161
|
+
* @example
|
|
162
|
+
* ```ts
|
|
163
|
+
* app.get('/api/data', cacheResponse({ ttl: 300 }), async (c) => {
|
|
164
|
+
* return c.json(await fetchData())
|
|
165
|
+
* })
|
|
166
|
+
* ```
|
|
167
|
+
*/
|
|
168
|
+
declare function cacheResponse(options: CacheOptions): MiddlewareHandler3;
|
|
169
|
+
import { EnvSchema as EnvSchema3, InferEnv as InferEnv2 } from "@workkit/env";
|
|
170
|
+
import { Context as Context2 } from "hono";
|
|
171
|
+
/**
|
|
172
|
+
* Get the validated, typed environment from Hono context.
|
|
173
|
+
*
|
|
174
|
+
* Requires the workkit() middleware to have run first.
|
|
175
|
+
* Throws if env has not been validated yet.
|
|
176
|
+
*
|
|
177
|
+
* @example
|
|
178
|
+
* ```ts
|
|
179
|
+
* app.get('/test', (c) => {
|
|
180
|
+
* const env = getEnv(c)
|
|
181
|
+
* return c.json({ key: env.API_KEY })
|
|
182
|
+
* })
|
|
183
|
+
* ```
|
|
184
|
+
*/
|
|
185
|
+
declare function getEnv<T extends EnvSchema3>(c: Context2<WorkkitEnv<T>>): InferEnv2<T>;
|
|
186
|
+
export { workkitErrorHandler, workkit, rateLimit, parseDuration, getEnv, fixedWindow, cacheResponse, WorkkitOptions, WorkkitEnv, RateLimiter, RateLimitResult, RateLimitOptions, FixedWindowOptions, ErrorHandlerOptions, CacheOptions };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { EnvSchema as EnvSchema2 } from "@workkit/env";
|
|
2
|
+
import { MiddlewareHandler } from "hono";
|
|
3
|
+
import { EnvSchema, InferEnv } from "@workkit/env";
|
|
4
|
+
import { Context, Env } from "hono";
|
|
5
|
+
/**
|
|
6
|
+
* Options for the workkit() middleware.
|
|
7
|
+
*/
|
|
8
|
+
interface WorkkitOptions<T extends EnvSchema> {
|
|
9
|
+
/** Environment schema to validate against on first request */
|
|
10
|
+
env: T;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Options for the workkitErrorHandler.
|
|
14
|
+
*/
|
|
15
|
+
interface ErrorHandlerOptions {
|
|
16
|
+
/** Include stack trace in error response (never in production) */
|
|
17
|
+
includeStack?: boolean;
|
|
18
|
+
/** Custom error callback for logging/reporting */
|
|
19
|
+
onError?: (err: Error, c: Context) => void | Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* A rate limiter instance that checks whether a key is allowed.
|
|
23
|
+
*/
|
|
24
|
+
interface RateLimiter {
|
|
25
|
+
/** Check if the key is allowed. Returns { allowed, remaining, resetAt } */
|
|
26
|
+
check(key: string): Promise<RateLimitResult>;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Result of a rate limit check.
|
|
30
|
+
*/
|
|
31
|
+
interface RateLimitResult {
|
|
32
|
+
/** Whether the request is allowed */
|
|
33
|
+
allowed: boolean;
|
|
34
|
+
/** Remaining requests in the current window */
|
|
35
|
+
remaining: number;
|
|
36
|
+
/** When the window resets (ms since epoch) */
|
|
37
|
+
resetAt: number;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Options for the rateLimit middleware.
|
|
41
|
+
*/
|
|
42
|
+
interface RateLimitOptions {
|
|
43
|
+
/** The rate limiter implementation */
|
|
44
|
+
limiter: RateLimiter;
|
|
45
|
+
/** Function to extract the rate limit key from context (e.g., IP address) */
|
|
46
|
+
keyFn: (c: Context) => string | Promise<string>;
|
|
47
|
+
/** Custom response when rate limited (optional) */
|
|
48
|
+
onRateLimited?: (c: Context, result: RateLimitResult) => Response | Promise<Response>;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Options for fixed-window rate limiter.
|
|
52
|
+
*/
|
|
53
|
+
interface FixedWindowOptions {
|
|
54
|
+
/** KV namespace for storing counters */
|
|
55
|
+
namespace: KVNamespace;
|
|
56
|
+
/** Maximum requests per window */
|
|
57
|
+
limit: number;
|
|
58
|
+
/** Window duration — e.g. '1m', '5m', '1h', '1d' */
|
|
59
|
+
window: string;
|
|
60
|
+
/** Optional prefix for KV keys */
|
|
61
|
+
prefix?: string;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Options for the cacheResponse middleware.
|
|
65
|
+
*/
|
|
66
|
+
interface CacheOptions {
|
|
67
|
+
/** Cache TTL in seconds */
|
|
68
|
+
ttl: number;
|
|
69
|
+
/** Function to generate the cache key (defaults to request URL) */
|
|
70
|
+
keyFn?: (c: Context) => string;
|
|
71
|
+
/** Cache API instance (defaults to caches.default) */
|
|
72
|
+
cache?: Cache;
|
|
73
|
+
/** HTTP methods to cache (defaults to ['GET']) */
|
|
74
|
+
methods?: string[];
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Hono environment type with workkit context variables.
|
|
78
|
+
*/
|
|
79
|
+
interface WorkkitEnv<T extends EnvSchema = EnvSchema> extends Env {
|
|
80
|
+
Variables: {
|
|
81
|
+
"workkit:env": InferEnv<T>;
|
|
82
|
+
"workkit:envValidated": boolean;
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Main workkit middleware — validates environment bindings on first request
|
|
87
|
+
* and stores the parsed, typed env in Hono's context.
|
|
88
|
+
*
|
|
89
|
+
* Validation runs once (on the first request) and the result is cached
|
|
90
|
+
* for subsequent requests within the same Worker invocation.
|
|
91
|
+
*
|
|
92
|
+
* @example
|
|
93
|
+
* ```ts
|
|
94
|
+
* const app = new Hono()
|
|
95
|
+
* app.use(workkit({ env: { API_KEY: z.string().min(1) } }))
|
|
96
|
+
* app.get('/', (c) => {
|
|
97
|
+
* const env = c.get('workkit:env') // typed
|
|
98
|
+
* })
|
|
99
|
+
* ```
|
|
100
|
+
*/
|
|
101
|
+
declare function workkit<T extends EnvSchema2>(options: WorkkitOptions<T>): MiddlewareHandler<WorkkitEnv<T>>;
|
|
102
|
+
import { ErrorHandler } from "hono";
|
|
103
|
+
/**
|
|
104
|
+
* Hono error handler that converts WorkkitErrors to proper HTTP responses.
|
|
105
|
+
*
|
|
106
|
+
* - WorkkitError instances → structured JSON with their status code
|
|
107
|
+
* - RateLimitError → includes Retry-After header
|
|
108
|
+
* - ValidationError → includes issues array
|
|
109
|
+
* - Unknown errors → 500 Internal Server Error
|
|
110
|
+
*
|
|
111
|
+
* @example
|
|
112
|
+
* ```ts
|
|
113
|
+
* app.onError(workkitErrorHandler({
|
|
114
|
+
* includeStack: false,
|
|
115
|
+
* onError: (err, c) => console.error(err),
|
|
116
|
+
* }))
|
|
117
|
+
* ```
|
|
118
|
+
*/
|
|
119
|
+
declare function workkitErrorHandler(options?: ErrorHandlerOptions): ErrorHandler;
|
|
120
|
+
import { MiddlewareHandler as MiddlewareHandler2 } from "hono";
|
|
121
|
+
/**
|
|
122
|
+
* Rate limit middleware for Hono.
|
|
123
|
+
*
|
|
124
|
+
* @example
|
|
125
|
+
* ```ts
|
|
126
|
+
* app.use('/api/*', rateLimit({
|
|
127
|
+
* limiter: fixedWindow({ namespace: env.KV, limit: 100, window: '1m' }),
|
|
128
|
+
* keyFn: (c) => c.req.header('CF-Connecting-IP') ?? 'unknown',
|
|
129
|
+
* }))
|
|
130
|
+
* ```
|
|
131
|
+
*/
|
|
132
|
+
declare function rateLimit(options: RateLimitOptions): MiddlewareHandler2;
|
|
133
|
+
/**
|
|
134
|
+
* Parse a duration string like '1m', '5m', '1h', '1d' into milliseconds.
|
|
135
|
+
*/
|
|
136
|
+
declare function parseDuration(duration: string): number;
|
|
137
|
+
/**
|
|
138
|
+
* Creates a fixed-window rate limiter backed by KV.
|
|
139
|
+
*
|
|
140
|
+
* Each window is stored as a KV key with an expiration TTL.
|
|
141
|
+
* Uses KV's eventual consistency — suitable for soft rate limiting,
|
|
142
|
+
* not cryptographic precision.
|
|
143
|
+
*
|
|
144
|
+
* @example
|
|
145
|
+
* ```ts
|
|
146
|
+
* const limiter = fixedWindow({
|
|
147
|
+
* namespace: env.RATE_LIMIT_KV,
|
|
148
|
+
* limit: 100,
|
|
149
|
+
* window: '1m',
|
|
150
|
+
* })
|
|
151
|
+
* ```
|
|
152
|
+
*/
|
|
153
|
+
declare function fixedWindow(options: FixedWindowOptions): RateLimiter;
|
|
154
|
+
import { MiddlewareHandler as MiddlewareHandler3 } from "hono";
|
|
155
|
+
/**
|
|
156
|
+
* Cache middleware for Hono — caches responses using the Cache API.
|
|
157
|
+
*
|
|
158
|
+
* Only caches successful (2xx) responses. Serves cached responses on cache hit.
|
|
159
|
+
* Uses Cloudflare's Cache API by default.
|
|
160
|
+
*
|
|
161
|
+
* @example
|
|
162
|
+
* ```ts
|
|
163
|
+
* app.get('/api/data', cacheResponse({ ttl: 300 }), async (c) => {
|
|
164
|
+
* return c.json(await fetchData())
|
|
165
|
+
* })
|
|
166
|
+
* ```
|
|
167
|
+
*/
|
|
168
|
+
declare function cacheResponse(options: CacheOptions): MiddlewareHandler3;
|
|
169
|
+
import { EnvSchema as EnvSchema3, InferEnv as InferEnv2 } from "@workkit/env";
|
|
170
|
+
import { Context as Context2 } from "hono";
|
|
171
|
+
/**
|
|
172
|
+
* Get the validated, typed environment from Hono context.
|
|
173
|
+
*
|
|
174
|
+
* Requires the workkit() middleware to have run first.
|
|
175
|
+
* Throws if env has not been validated yet.
|
|
176
|
+
*
|
|
177
|
+
* @example
|
|
178
|
+
* ```ts
|
|
179
|
+
* app.get('/test', (c) => {
|
|
180
|
+
* const env = getEnv(c)
|
|
181
|
+
* return c.json({ key: env.API_KEY })
|
|
182
|
+
* })
|
|
183
|
+
* ```
|
|
184
|
+
*/
|
|
185
|
+
declare function getEnv<T extends EnvSchema3>(c: Context2<WorkkitEnv<T>>): InferEnv2<T>;
|
|
186
|
+
export { workkitErrorHandler, workkit, rateLimit, parseDuration, getEnv, fixedWindow, cacheResponse, WorkkitOptions, WorkkitEnv, RateLimiter, RateLimitResult, RateLimitOptions, FixedWindowOptions, ErrorHandlerOptions, CacheOptions };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// src/middleware.ts
|
|
2
|
+
import { parseEnv } from "@workkit/env";
|
|
3
|
+
function workkit(options) {
|
|
4
|
+
let cachedEnv = null;
|
|
5
|
+
return async (c, next) => {
|
|
6
|
+
if (!cachedEnv) {
|
|
7
|
+
const rawEnv = c.env;
|
|
8
|
+
cachedEnv = await parseEnv(rawEnv, options.env);
|
|
9
|
+
}
|
|
10
|
+
c.set("workkit:env", cachedEnv);
|
|
11
|
+
c.set("workkit:envValidated", true);
|
|
12
|
+
await next();
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
// src/error-handler.ts
|
|
16
|
+
import { InternalError, RateLimitError, isWorkkitError } from "@workkit/errors";
|
|
17
|
+
function workkitErrorHandler(options = {}) {
|
|
18
|
+
const { includeStack = false, onError } = options;
|
|
19
|
+
return async (err, c) => {
|
|
20
|
+
if (onError) {
|
|
21
|
+
try {
|
|
22
|
+
await onError(err, c);
|
|
23
|
+
} catch {}
|
|
24
|
+
}
|
|
25
|
+
if (isWorkkitError(err)) {
|
|
26
|
+
return workkitErrorToResponse(err, includeStack);
|
|
27
|
+
}
|
|
28
|
+
const wrapped = new InternalError(err instanceof Error ? err.message : "An unexpected error occurred", { cause: err });
|
|
29
|
+
return workkitErrorToResponse(wrapped, includeStack);
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function workkitErrorToResponse(error, includeStack) {
|
|
33
|
+
const body = {
|
|
34
|
+
error: {
|
|
35
|
+
code: error.code,
|
|
36
|
+
message: error.message,
|
|
37
|
+
statusCode: error.statusCode
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
if ("issues" in error && Array.isArray(error.issues)) {
|
|
41
|
+
body.error.issues = error.issues;
|
|
42
|
+
}
|
|
43
|
+
if (includeStack && error.stack) {
|
|
44
|
+
body.error.stack = error.stack;
|
|
45
|
+
}
|
|
46
|
+
const headers = {
|
|
47
|
+
"Content-Type": "application/json"
|
|
48
|
+
};
|
|
49
|
+
if (error instanceof RateLimitError && error.retryAfterMs) {
|
|
50
|
+
headers["Retry-After"] = String(Math.ceil(error.retryAfterMs / 1000));
|
|
51
|
+
}
|
|
52
|
+
return new Response(JSON.stringify(body), {
|
|
53
|
+
status: error.statusCode,
|
|
54
|
+
headers
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
// src/rate-limit.ts
|
|
58
|
+
import { RateLimitError as RateLimitError2 } from "@workkit/errors";
|
|
59
|
+
function rateLimit(options) {
|
|
60
|
+
const { limiter, keyFn, onRateLimited } = options;
|
|
61
|
+
return async (c, next) => {
|
|
62
|
+
const key = await keyFn(c);
|
|
63
|
+
const result = await limiter.check(key);
|
|
64
|
+
c.header("X-RateLimit-Limit", String(result.remaining + (result.allowed ? 0 : 1)));
|
|
65
|
+
c.header("X-RateLimit-Remaining", String(result.remaining));
|
|
66
|
+
c.header("X-RateLimit-Reset", String(Math.ceil(result.resetAt / 1000)));
|
|
67
|
+
if (!result.allowed) {
|
|
68
|
+
if (onRateLimited) {
|
|
69
|
+
return onRateLimited(c, result);
|
|
70
|
+
}
|
|
71
|
+
const retryAfterMs = result.resetAt - Date.now();
|
|
72
|
+
throw new RateLimitError2("Rate limit exceeded", retryAfterMs > 0 ? retryAfterMs : undefined);
|
|
73
|
+
}
|
|
74
|
+
await next();
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function parseDuration(duration) {
|
|
78
|
+
const match = duration.match(/^(\d+)(s|m|h|d)$/);
|
|
79
|
+
if (!match) {
|
|
80
|
+
throw new Error(`Invalid duration format: "${duration}". Use e.g. '1m', '5m', '1h', '1d'.`);
|
|
81
|
+
}
|
|
82
|
+
const value = Number.parseInt(match[1], 10);
|
|
83
|
+
const unit = match[2];
|
|
84
|
+
switch (unit) {
|
|
85
|
+
case "s":
|
|
86
|
+
return value * 1000;
|
|
87
|
+
case "m":
|
|
88
|
+
return value * 60 * 1000;
|
|
89
|
+
case "h":
|
|
90
|
+
return value * 60 * 60 * 1000;
|
|
91
|
+
case "d":
|
|
92
|
+
return value * 24 * 60 * 60 * 1000;
|
|
93
|
+
default:
|
|
94
|
+
throw new Error(`Unknown duration unit: "${unit}"`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function fixedWindow(options) {
|
|
98
|
+
const { namespace, limit, window: windowStr, prefix = "rl:" } = options;
|
|
99
|
+
const windowMs = parseDuration(windowStr);
|
|
100
|
+
return {
|
|
101
|
+
async check(key) {
|
|
102
|
+
const now = Date.now();
|
|
103
|
+
const windowStart = Math.floor(now / windowMs) * windowMs;
|
|
104
|
+
const resetAt = windowStart + windowMs;
|
|
105
|
+
const kvKey = `${prefix}${key}:${windowStart}`;
|
|
106
|
+
const current = await namespace.get(kvKey);
|
|
107
|
+
const count = current ? Number.parseInt(current, 10) : 0;
|
|
108
|
+
if (count >= limit) {
|
|
109
|
+
return { allowed: false, remaining: 0, resetAt };
|
|
110
|
+
}
|
|
111
|
+
const ttlSeconds = Math.ceil(windowMs / 1000);
|
|
112
|
+
await namespace.put(kvKey, String(count + 1), {
|
|
113
|
+
expirationTtl: ttlSeconds
|
|
114
|
+
});
|
|
115
|
+
return { allowed: true, remaining: limit - count - 1, resetAt };
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
// src/cache.ts
|
|
120
|
+
function cacheResponse(options) {
|
|
121
|
+
const { ttl, keyFn, methods = ["GET"] } = options;
|
|
122
|
+
return async (c, next) => {
|
|
123
|
+
if (!methods.includes(c.req.method)) {
|
|
124
|
+
await next();
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const cacheKey = keyFn ? keyFn(c) : c.req.url;
|
|
128
|
+
const cacheRequest = new Request(cacheKey);
|
|
129
|
+
const cache = options.cache ?? (typeof caches !== "undefined" ? caches.default : null);
|
|
130
|
+
if (!cache) {
|
|
131
|
+
await next();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const cached = await cache.match(cacheRequest);
|
|
135
|
+
if (cached) {
|
|
136
|
+
return cached;
|
|
137
|
+
}
|
|
138
|
+
await next();
|
|
139
|
+
const response = c.res;
|
|
140
|
+
if (response.status >= 200 && response.status < 300) {
|
|
141
|
+
const cloned = response.clone();
|
|
142
|
+
const cachedResponse = new Response(cloned.body, {
|
|
143
|
+
status: cloned.status,
|
|
144
|
+
statusText: cloned.statusText,
|
|
145
|
+
headers: new Headers(cloned.headers)
|
|
146
|
+
});
|
|
147
|
+
cachedResponse.headers.set("Cache-Control", `s-maxage=${ttl}`);
|
|
148
|
+
const putPromise = cache.put(cacheRequest, cachedResponse);
|
|
149
|
+
try {
|
|
150
|
+
c.executionCtx.waitUntil(putPromise);
|
|
151
|
+
} catch {
|
|
152
|
+
await putPromise;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
// src/helpers.ts
|
|
158
|
+
function getEnv(c) {
|
|
159
|
+
const validated = c.get("workkit:envValidated");
|
|
160
|
+
if (!validated) {
|
|
161
|
+
throw new Error("workkit:env is not available. Did you forget to add the workkit() middleware?");
|
|
162
|
+
}
|
|
163
|
+
return c.get("workkit:env");
|
|
164
|
+
}
|
|
165
|
+
export {
|
|
166
|
+
workkitErrorHandler,
|
|
167
|
+
workkit,
|
|
168
|
+
rateLimit,
|
|
169
|
+
parseDuration,
|
|
170
|
+
getEnv,
|
|
171
|
+
fixedWindow,
|
|
172
|
+
cacheResponse
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
//# debugId=0CE4411E8BEEF18964756E2164756E21
|
|
176
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["src/middleware.ts", "src/error-handler.ts", "src/rate-limit.ts", "src/cache.ts", "src/helpers.ts"],
|
|
4
|
+
"sourcesContent": [
|
|
5
|
+
"import { parseEnv } from \"@workkit/env\";\nimport type { EnvSchema, InferEnv } from \"@workkit/env\";\nimport type { MiddlewareHandler } from \"hono\";\nimport type { WorkkitEnv, WorkkitOptions } from \"./types\";\n\n/**\n * Main workkit middleware — validates environment bindings on first request\n * and stores the parsed, typed env in Hono's context.\n *\n * Validation runs once (on the first request) and the result is cached\n * for subsequent requests within the same Worker invocation.\n *\n * @example\n * ```ts\n * const app = new Hono()\n * app.use(workkit({ env: { API_KEY: z.string().min(1) } }))\n * app.get('/', (c) => {\n * const env = c.get('workkit:env') // typed\n * })\n * ```\n */\nexport function workkit<T extends EnvSchema>(\n\toptions: WorkkitOptions<T>,\n): MiddlewareHandler<WorkkitEnv<T>> {\n\tlet cachedEnv: InferEnv<T> | null = null;\n\n\treturn async (c, next) => {\n\t\tif (!cachedEnv) {\n\t\t\tconst rawEnv = c.env as Record<string, unknown>;\n\t\t\tcachedEnv = await parseEnv(rawEnv, options.env);\n\t\t}\n\n\t\tc.set(\"workkit:env\", cachedEnv);\n\t\tc.set(\"workkit:envValidated\", true);\n\n\t\tawait next();\n\t};\n}\n",
|
|
6
|
+
"import { InternalError, RateLimitError, type WorkkitError, isWorkkitError } from \"@workkit/errors\";\nimport type { ErrorHandler } from \"hono\";\nimport type { ErrorHandlerOptions } from \"./types\";\n\n/**\n * Hono error handler that converts WorkkitErrors to proper HTTP responses.\n *\n * - WorkkitError instances → structured JSON with their status code\n * - RateLimitError → includes Retry-After header\n * - ValidationError → includes issues array\n * - Unknown errors → 500 Internal Server Error\n *\n * @example\n * ```ts\n * app.onError(workkitErrorHandler({\n * includeStack: false,\n * onError: (err, c) => console.error(err),\n * }))\n * ```\n */\nexport function workkitErrorHandler(options: ErrorHandlerOptions = {}): ErrorHandler {\n\tconst { includeStack = false, onError } = options;\n\n\treturn async (err, c) => {\n\t\tif (onError) {\n\t\t\ttry {\n\t\t\t\tawait onError(err, c);\n\t\t\t} catch {\n\t\t\t\t// Don't let error callback failures break the response\n\t\t\t}\n\t\t}\n\n\t\tif (isWorkkitError(err)) {\n\t\t\treturn workkitErrorToResponse(err, includeStack);\n\t\t}\n\n\t\t// Wrap unknown errors as InternalError\n\t\tconst wrapped = new InternalError(\n\t\t\terr instanceof Error ? err.message : \"An unexpected error occurred\",\n\t\t\t{ cause: err },\n\t\t);\n\n\t\treturn workkitErrorToResponse(wrapped, includeStack);\n\t};\n}\n\nfunction workkitErrorToResponse(error: WorkkitError, includeStack: boolean): Response {\n\tconst body: Record<string, unknown> = {\n\t\terror: {\n\t\t\tcode: error.code,\n\t\t\tmessage: error.message,\n\t\t\tstatusCode: error.statusCode,\n\t\t},\n\t};\n\n\t// Include validation issues if present\n\tif (\"issues\" in error && Array.isArray((error as any).issues)) {\n\t\t(body.error as any).issues = (error as any).issues;\n\t}\n\n\tif (includeStack && error.stack) {\n\t\t(body.error as any).stack = error.stack;\n\t}\n\n\tconst headers: Record<string, string> = {\n\t\t\"Content-Type\": \"application/json\",\n\t};\n\n\t// Set Retry-After header for rate limit errors\n\tif (error instanceof RateLimitError && error.retryAfterMs) {\n\t\theaders[\"Retry-After\"] = String(Math.ceil(error.retryAfterMs / 1000));\n\t}\n\n\treturn new Response(JSON.stringify(body), {\n\t\tstatus: error.statusCode,\n\t\theaders,\n\t});\n}\n",
|
|
7
|
+
"import { RateLimitError } from \"@workkit/errors\";\nimport type { MiddlewareHandler } from \"hono\";\nimport type { FixedWindowOptions, RateLimitOptions, RateLimitResult, RateLimiter } from \"./types\";\n\n/**\n * Rate limit middleware for Hono.\n *\n * @example\n * ```ts\n * app.use('/api/*', rateLimit({\n * limiter: fixedWindow({ namespace: env.KV, limit: 100, window: '1m' }),\n * keyFn: (c) => c.req.header('CF-Connecting-IP') ?? 'unknown',\n * }))\n * ```\n */\nexport function rateLimit(options: RateLimitOptions): MiddlewareHandler {\n\tconst { limiter, keyFn, onRateLimited } = options;\n\n\treturn async (c, next) => {\n\t\tconst key = await keyFn(c);\n\t\tconst result = await limiter.check(key);\n\n\t\t// Set rate limit headers regardless of outcome\n\t\tc.header(\"X-RateLimit-Limit\", String(result.remaining + (result.allowed ? 0 : 1)));\n\t\tc.header(\"X-RateLimit-Remaining\", String(result.remaining));\n\t\tc.header(\"X-RateLimit-Reset\", String(Math.ceil(result.resetAt / 1000)));\n\n\t\tif (!result.allowed) {\n\t\t\tif (onRateLimited) {\n\t\t\t\treturn onRateLimited(c, result);\n\t\t\t}\n\n\t\t\tconst retryAfterMs = result.resetAt - Date.now();\n\t\t\tthrow new RateLimitError(\"Rate limit exceeded\", retryAfterMs > 0 ? retryAfterMs : undefined);\n\t\t}\n\n\t\tawait next();\n\t};\n}\n\n/**\n * Parse a duration string like '1m', '5m', '1h', '1d' into milliseconds.\n */\nexport function parseDuration(duration: string): number {\n\tconst match = duration.match(/^(\\d+)(s|m|h|d)$/);\n\tif (!match) {\n\t\tthrow new Error(`Invalid duration format: \"${duration}\". Use e.g. '1m', '5m', '1h', '1d'.`);\n\t}\n\n\tconst value = Number.parseInt(match[1]!, 10);\n\tconst unit = match[2]!;\n\n\tswitch (unit) {\n\t\tcase \"s\":\n\t\t\treturn value * 1000;\n\t\tcase \"m\":\n\t\t\treturn value * 60 * 1000;\n\t\tcase \"h\":\n\t\t\treturn value * 60 * 60 * 1000;\n\t\tcase \"d\":\n\t\t\treturn value * 24 * 60 * 60 * 1000;\n\t\tdefault:\n\t\t\tthrow new Error(`Unknown duration unit: \"${unit}\"`);\n\t}\n}\n\n/**\n * Creates a fixed-window rate limiter backed by KV.\n *\n * Each window is stored as a KV key with an expiration TTL.\n * Uses KV's eventual consistency — suitable for soft rate limiting,\n * not cryptographic precision.\n *\n * @example\n * ```ts\n * const limiter = fixedWindow({\n * namespace: env.RATE_LIMIT_KV,\n * limit: 100,\n * window: '1m',\n * })\n * ```\n */\nexport function fixedWindow(options: FixedWindowOptions): RateLimiter {\n\tconst { namespace, limit, window: windowStr, prefix = \"rl:\" } = options;\n\tconst windowMs = parseDuration(windowStr);\n\n\treturn {\n\t\tasync check(key: string): Promise<RateLimitResult> {\n\t\t\tconst now = Date.now();\n\t\t\tconst windowStart = Math.floor(now / windowMs) * windowMs;\n\t\t\tconst resetAt = windowStart + windowMs;\n\t\t\tconst kvKey = `${prefix}${key}:${windowStart}`;\n\n\t\t\tconst current = await namespace.get(kvKey);\n\t\t\tconst count = current ? Number.parseInt(current, 10) : 0;\n\n\t\t\tif (count >= limit) {\n\t\t\t\treturn { allowed: false, remaining: 0, resetAt };\n\t\t\t}\n\n\t\t\t// Increment counter with TTL equal to window duration (in seconds, rounded up)\n\t\t\tconst ttlSeconds = Math.ceil(windowMs / 1000);\n\t\t\tawait namespace.put(kvKey, String(count + 1), {\n\t\t\t\texpirationTtl: ttlSeconds,\n\t\t\t});\n\n\t\t\treturn { allowed: true, remaining: limit - count - 1, resetAt };\n\t\t},\n\t};\n}\n",
|
|
8
|
+
"import type { MiddlewareHandler } from \"hono\";\nimport type { CacheOptions } from \"./types\";\n\n/**\n * Cache middleware for Hono — caches responses using the Cache API.\n *\n * Only caches successful (2xx) responses. Serves cached responses on cache hit.\n * Uses Cloudflare's Cache API by default.\n *\n * @example\n * ```ts\n * app.get('/api/data', cacheResponse({ ttl: 300 }), async (c) => {\n * return c.json(await fetchData())\n * })\n * ```\n */\nexport function cacheResponse(options: CacheOptions): MiddlewareHandler {\n\tconst { ttl, keyFn, methods = [\"GET\"] } = options;\n\n\treturn async (c, next) => {\n\t\t// Only cache specified methods\n\t\tif (!methods.includes(c.req.method)) {\n\t\t\tawait next();\n\t\t\treturn;\n\t\t}\n\n\t\tconst cacheKey = keyFn ? keyFn(c) : c.req.url;\n\t\tconst cacheRequest = new Request(cacheKey);\n\n\t\t// Try to get the cache instance\n\t\tconst cache = options.cache ?? (typeof caches !== \"undefined\" ? caches.default : null);\n\t\tif (!cache) {\n\t\t\t// No cache available, skip caching\n\t\t\tawait next();\n\t\t\treturn;\n\t\t}\n\n\t\t// Check for cached response\n\t\tconst cached = await cache.match(cacheRequest);\n\t\tif (cached) {\n\t\t\treturn cached;\n\t\t}\n\n\t\t// Execute handler\n\t\tawait next();\n\n\t\t// Only cache successful responses\n\t\tconst response = c.res;\n\t\tif (response.status >= 200 && response.status < 300) {\n\t\t\t// Clone the response and add cache headers\n\t\t\tconst cloned = response.clone();\n\t\t\tconst cachedResponse = new Response(cloned.body, {\n\t\t\t\tstatus: cloned.status,\n\t\t\t\tstatusText: cloned.statusText,\n\t\t\t\theaders: new Headers(cloned.headers),\n\t\t\t});\n\t\t\tcachedResponse.headers.set(\"Cache-Control\", `s-maxage=${ttl}`);\n\n\t\t\t// Store in cache — use waitUntil if available, otherwise await directly\n\t\t\tconst putPromise = cache.put(cacheRequest, cachedResponse);\n\t\t\ttry {\n\t\t\t\tc.executionCtx.waitUntil(putPromise);\n\t\t\t} catch {\n\t\t\t\t// executionCtx not available (e.g., in tests), await directly\n\t\t\t\tawait putPromise;\n\t\t\t}\n\t\t}\n\t};\n}\n",
|
|
9
|
+
"import type { EnvSchema, InferEnv } from \"@workkit/env\";\nimport type { Context } from \"hono\";\nimport type { WorkkitEnv } from \"./types\";\n\n/**\n * Get the validated, typed environment from Hono context.\n *\n * Requires the workkit() middleware to have run first.\n * Throws if env has not been validated yet.\n *\n * @example\n * ```ts\n * app.get('/test', (c) => {\n * const env = getEnv(c)\n * return c.json({ key: env.API_KEY })\n * })\n * ```\n */\nexport function getEnv<T extends EnvSchema>(c: Context<WorkkitEnv<T>>): InferEnv<T> {\n\tconst validated = c.get(\"workkit:envValidated\");\n\tif (!validated) {\n\t\tthrow new Error(\n\t\t\t\"workkit:env is not available. Did you forget to add the workkit() middleware?\",\n\t\t);\n\t}\n\treturn c.get(\"workkit:env\");\n}\n"
|
|
10
|
+
],
|
|
11
|
+
"mappings": "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",
|
|
12
|
+
"debugId": "4C2D147FF9900DA464756E2164756E21",
|
|
13
|
+
"names": []
|
|
14
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@workkit/hono",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Hono middleware that integrates workkit utilities — env validation, error handling, rate limiting, caching",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Bikash Dash <beeeku>",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/beeeku/workkit",
|
|
10
|
+
"directory": "integrations/hono"
|
|
11
|
+
},
|
|
12
|
+
"type": "module",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"import": {
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"default": "./dist/index.js"
|
|
18
|
+
},
|
|
19
|
+
"require": {
|
|
20
|
+
"types": "./dist/index.d.cts",
|
|
21
|
+
"default": "./dist/index.cjs"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"main": "./dist/index.cjs",
|
|
26
|
+
"module": "./dist/index.js",
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"files": ["dist"],
|
|
29
|
+
"sideEffects": false,
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "bunup",
|
|
32
|
+
"test": "vitest run",
|
|
33
|
+
"test:watch": "vitest",
|
|
34
|
+
"typecheck": "tsc --noEmit",
|
|
35
|
+
"clean": "rm -rf dist"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@workkit/types": "workspace:*",
|
|
39
|
+
"@workkit/errors": "workspace:*",
|
|
40
|
+
"@workkit/env": "workspace:*"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"hono": ">=4.0.0"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@cloudflare/workers-types": "^4.20250310.0",
|
|
47
|
+
"bunup": "0.16.31",
|
|
48
|
+
"expect-type": "^1.1.0",
|
|
49
|
+
"hono": "^4.7.0",
|
|
50
|
+
"typescript": "^5.7.0",
|
|
51
|
+
"vitest": "^3.0.0"
|
|
52
|
+
},
|
|
53
|
+
"keywords": [
|
|
54
|
+
"cloudflare",
|
|
55
|
+
"workers",
|
|
56
|
+
"hono",
|
|
57
|
+
"middleware",
|
|
58
|
+
"env",
|
|
59
|
+
"validation",
|
|
60
|
+
"error-handler",
|
|
61
|
+
"rate-limit",
|
|
62
|
+
"cache",
|
|
63
|
+
"workkit"
|
|
64
|
+
]
|
|
65
|
+
}
|