@lovrozagar/honey 0.1.0

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.
Files changed (131) hide show
  1. package/README.md +51 -0
  2. package/docs/api-ref/go.md +399 -0
  3. package/docs/api-ref/python.md +399 -0
  4. package/docs/api-ref/rust.md +399 -0
  5. package/docs/api-ref/typescript.md +399 -0
  6. package/docs/invalidate-spec.md +303 -0
  7. package/docs/sdk.md +722 -0
  8. package/package.json +120 -0
  9. package/src/accepts.ts +78 -0
  10. package/src/body-limit.ts +100 -0
  11. package/src/build/index.ts +174 -0
  12. package/src/cli-go/auth.go +15 -0
  13. package/src/cli-go/config.go +165 -0
  14. package/src/cli-go/errors.go +57 -0
  15. package/src/cli-go/multipart.go +55 -0
  16. package/src/cli-go/output.go +109 -0
  17. package/src/cli-go/root.go +30 -0
  18. package/src/cli-go/stream.go +88 -0
  19. package/src/cli-go/version.go +5 -0
  20. package/src/cli.ts +204 -0
  21. package/src/client/error.ts +27 -0
  22. package/src/client/http.ts +502 -0
  23. package/src/client/index.ts +127 -0
  24. package/src/client/sdk.ts +265 -0
  25. package/src/client/sse.ts +102 -0
  26. package/src/client/types.ts +211 -0
  27. package/src/client/ws.ts +115 -0
  28. package/src/client-go/errors.go +207 -0
  29. package/src/client-go/invalidation.go +373 -0
  30. package/src/client-go/realtime.go +317 -0
  31. package/src/client-go/result.go +20 -0
  32. package/src/client-go/runtime.go +413 -0
  33. package/src/client-go/sse.go +149 -0
  34. package/src/client-go/transport.go +452 -0
  35. package/src/client-go/ws.go +104 -0
  36. package/src/client-mcp/README.md +10 -0
  37. package/src/client-mcp/server.ts +94 -0
  38. package/src/client-mcp/types.ts +19 -0
  39. package/src/client-python/_errors.py +115 -0
  40. package/src/client-python/_invalidation.py +578 -0
  41. package/src/client-python/_realtime.py +156 -0
  42. package/src/client-python/_runtime.py +469 -0
  43. package/src/client-python/_sse.py +99 -0
  44. package/src/client-python/_transport.py +335 -0
  45. package/src/client-python/_ws.py +131 -0
  46. package/src/client-python/py.typed +0 -0
  47. package/src/client-rust/errors.rs +200 -0
  48. package/src/client-rust/invalidation.rs +441 -0
  49. package/src/client-rust/invalidation_sync.rs +306 -0
  50. package/src/client-rust/realtime.rs +695 -0
  51. package/src/client-rust/result.rs +22 -0
  52. package/src/client-rust/runtime.rs +547 -0
  53. package/src/client-rust/runtime_sync.rs +452 -0
  54. package/src/client-rust/sse.rs +167 -0
  55. package/src/client-rust/ws.rs +143 -0
  56. package/src/cloudflare/realtime.ts +236 -0
  57. package/src/codegen-go-cli.ts +1180 -0
  58. package/src/codegen-go.ts +1010 -0
  59. package/src/codegen-hash.ts +8 -0
  60. package/src/codegen-ir.ts +517 -0
  61. package/src/codegen-loaders.ts +53 -0
  62. package/src/codegen-mcp.ts +364 -0
  63. package/src/codegen-python.ts +1028 -0
  64. package/src/codegen-rust.ts +1652 -0
  65. package/src/codegen-sanitize.ts +67 -0
  66. package/src/codegen-schema-naming.ts +146 -0
  67. package/src/codegen.ts +5015 -0
  68. package/src/context.ts +210 -0
  69. package/src/cookie-sign.ts +58 -0
  70. package/src/cookie.ts +89 -0
  71. package/src/cors.ts +96 -0
  72. package/src/crypto.ts +19 -0
  73. package/src/csrf.ts +54 -0
  74. package/src/curl-logger.ts +235 -0
  75. package/src/detect-runtime.ts +19 -0
  76. package/src/error.ts +52 -0
  77. package/src/errors.ts +87 -0
  78. package/src/etag.ts +74 -0
  79. package/src/feature-load.ts +23 -0
  80. package/src/go-type-emitter.ts +404 -0
  81. package/src/honey-response.ts +354 -0
  82. package/src/i18n-slot.ts +20 -0
  83. package/src/i18n.ts +234 -0
  84. package/src/index.ts +3843 -0
  85. package/src/init.ts +149 -0
  86. package/src/input.ts +8 -0
  87. package/src/ip-restrict.ts +192 -0
  88. package/src/logger.ts +149 -0
  89. package/src/middleware.ts +199 -0
  90. package/src/node-request.ts +240 -0
  91. package/src/node.ts +212 -0
  92. package/src/openapi/register.ts +19 -0
  93. package/src/openapi/scalar.ts +33 -0
  94. package/src/openapi/spec-factory.ts +46 -0
  95. package/src/openapi/spec.ts +31 -0
  96. package/src/openapi/swagger.ts +34 -0
  97. package/src/plugin.ts +771 -0
  98. package/src/powered-by.ts +17 -0
  99. package/src/pretty-json.ts +39 -0
  100. package/src/proxy.ts +155 -0
  101. package/src/python-type-emitter.ts +144 -0
  102. package/src/realtime/buffer.ts +101 -0
  103. package/src/realtime/bus.ts +93 -0
  104. package/src/realtime/longpoll.ts +148 -0
  105. package/src/realtime/protocol.ts +128 -0
  106. package/src/realtime/route.ts +104 -0
  107. package/src/realtime/runtime.ts +430 -0
  108. package/src/request-id.ts +23 -0
  109. package/src/request-to-curl.ts +34 -0
  110. package/src/response.ts +414 -0
  111. package/src/rust-type-emitter.ts +412 -0
  112. package/src/secure-headers.ts +69 -0
  113. package/src/serve-register.ts +8 -0
  114. package/src/serve-slot.ts +20 -0
  115. package/src/serve.ts +141 -0
  116. package/src/server-timing.ts +66 -0
  117. package/src/static.ts +60 -0
  118. package/src/telemetry/otel.ts +139 -0
  119. package/src/testing.ts +114 -0
  120. package/src/timeout.ts +36 -0
  121. package/src/tree.ts +464 -0
  122. package/src/ts-type-emitter.ts +106 -0
  123. package/src/type-emitter.ts +524 -0
  124. package/src/type-extractor.ts +749 -0
  125. package/src/types.ts +638 -0
  126. package/src/validation.ts +300 -0
  127. package/src/ws/bun.ts +89 -0
  128. package/src/ws/cloudflare.ts +133 -0
  129. package/src/ws/deno.ts +109 -0
  130. package/src/ws/node.ts +161 -0
  131. package/src/yaml.ts +80 -0
package/src/context.ts ADDED
@@ -0,0 +1,210 @@
1
+ import { isNodeOutbound } from "./honey-response.ts";
2
+ import type { SSEOptions, SSEStream, TypedResponse } from "./response.ts";
3
+ import { HoneyRes } from "./response.ts";
4
+ import type { PendingTap } from "./types.ts";
5
+ import { EMPTY_OBJ } from "./types.ts";
6
+ import { parseCookies } from "./validation.ts";
7
+
8
+ /**
9
+ * Per-request res with lazy lastEventId — only reads the header when SSE is actually called.
10
+ * All non-SSE methods (json/text/html/etc) are inherited from HoneyRes with zero overhead.
11
+ */
12
+ class ContextRes extends HoneyRes {
13
+ private _req: Request;
14
+ private _lastEventId: string | undefined | false = false;
15
+
16
+ constructor(req: Request) {
17
+ super(isNodeOutbound(req));
18
+ this._req = req;
19
+ }
20
+
21
+ override sse(
22
+ callback: (stream: SSEStream) => Promise<void>,
23
+ opts?: SSEOptions,
24
+ ): TypedResponse<"text/event-stream", "ok"> {
25
+ if (this._lastEventId === false) {
26
+ this._lastEventId = this._req.headers.get("last-event-id") ?? undefined;
27
+ }
28
+ return super.sse(callback, {
29
+ ...opts,
30
+ lastEventId: opts?.lastEventId ?? this._lastEventId,
31
+ });
32
+ }
33
+ }
34
+ function parseSearch(ctx: HoneyContext): void {
35
+ const url = ctx._lzUrlFn ? ctx._lzUrlFn() : new URL(ctx.req.url);
36
+ const first: Record<string, string> = {};
37
+ const all: Record<string, string[]> = {};
38
+ for (const [key, value] of url.searchParams) {
39
+ if (first[key] === undefined) {
40
+ first[key] = value;
41
+ all[key] = [value];
42
+ } else {
43
+ const values = all[key];
44
+ if (values) {
45
+ values.push(value);
46
+ }
47
+ }
48
+ }
49
+ ctx._lzSearch = first;
50
+ ctx._lzSearchAll = all;
51
+ }
52
+
53
+ function bgSwallow(p: Promise<unknown>) {
54
+ p.catch(noop);
55
+ }
56
+ function noop() {}
57
+
58
+ /**
59
+ * HoneyContext uses `declare` for properties set after construction and
60
+ * class-level getters for lazy-computed properties. No Object.defineProperty
61
+ * on the hot path — V8 optimizes class shapes via hidden classes.
62
+ *
63
+ * `declare` fields have no private brand, so Omit<HoneyContext, "res">
64
+ * works structurally for ApplyOutput type narrowing.
65
+ */
66
+ export class HoneyContext<TEnv = Record<string, unknown>> {
67
+ readonly background: (p: Promise<unknown>) => void;
68
+ readonly env: TEnv;
69
+ readonly params: Record<string, string>;
70
+ readonly req: Request;
71
+ readonly res: HoneyRes;
72
+
73
+ /* set after construction — declare keeps them off the private brand */
74
+ declare readonly cookies: Record<string, string>;
75
+ declare readonly errors: Record<string, (...args: never[]) => unknown>;
76
+ declare readonly executionCtx:
77
+ | { waitUntil?: (p: Promise<unknown>) => void }
78
+ | undefined;
79
+ declare readonly headers: Record<string, string>;
80
+ declare readonly meta: Record<string, unknown>;
81
+ declare readonly path: string;
82
+ declare readonly realtime: { publish(topic: string, data: unknown): void };
83
+ declare readonly routePattern: string;
84
+ declare readonly search: Record<string, string>;
85
+ declare readonly searchAll: Record<string, string[]>;
86
+
87
+ /* backing state for lazy getters — stored directly on instance to avoid extra object allocation */
88
+ /** @internal */ _lzCookies: Record<string, string> | null;
89
+ /** @internal */ _lzHeaders: Record<string, string> | null;
90
+ /** @internal */ _lzSearch: Record<string, string> | null;
91
+ /** @internal */ _lzSearchAll: Record<string, string[]> | null;
92
+ /** @internal */ _lzUrlFn: (() => URL) | null;
93
+
94
+ /* error propagation — lets handler errors flow back through middleware chain */
95
+ /** @internal */ _errorToResponse:
96
+ | ((thrown: unknown) => Promise<Response>)
97
+ | null;
98
+ /** @internal */ _isErrorResponse: boolean;
99
+
100
+ /* tap side-effects — queued by c.tap(), drained after handler */
101
+ /** @internal */ _pendingTaps: PendingTap[] | null;
102
+
103
+ constructor(opts: {
104
+ env: TEnv;
105
+ executionCtx?: { waitUntil?: (p: Promise<unknown>) => void };
106
+ meta?: Record<string, unknown>;
107
+ params: Record<string, string>;
108
+ path?: string;
109
+ req: Request;
110
+ routePattern?: string;
111
+ urlFn?: () => URL;
112
+ }) {
113
+ this.req = opts.req;
114
+ this.env = opts.env;
115
+ this.params = opts.params;
116
+ this.res = new ContextRes(opts.req);
117
+
118
+ /* direct assignment — no defineProperty */
119
+ (this as Record<string, unknown>)["executionCtx"] = opts.executionCtx;
120
+ (this as Record<string, unknown>)["path"] = opts.path ?? "";
121
+ (this as Record<string, unknown>)["routePattern"] = opts.routePattern ?? "";
122
+ (this as Record<string, unknown>)["meta"] = opts.meta ?? EMPTY_OBJ;
123
+
124
+ /* invoke through executionCtx so `this` binds to the owning object — workerd's native ExecutionContext.waitUntil throws "Illegal invocation" when called detached */
125
+ const executionCtx = opts.executionCtx;
126
+ const waitUntil = executionCtx?.waitUntil;
127
+ this.background = waitUntil
128
+ ? (p: Promise<unknown>) => {
129
+ waitUntil.call(executionCtx, p);
130
+ }
131
+ : bgSwallow;
132
+
133
+ /* lazy getter backing — flat on instance, no extra object */
134
+ this._lzCookies = null;
135
+ this._lzHeaders = null;
136
+ this._lzSearch = null;
137
+ this._lzSearchAll = null;
138
+ this._lzUrlFn = opts.urlFn ?? null;
139
+
140
+ /* error propagation */
141
+ this._errorToResponse = null;
142
+ this._isErrorResponse = false;
143
+
144
+ /* taps */
145
+ this._pendingTaps = null;
146
+ }
147
+
148
+ /** @internal */
149
+ _setErrors(errors: Record<string, (...args: never[]) => unknown>): void {
150
+ (this as { errors: typeof errors }).errors = errors;
151
+ }
152
+
153
+ /** Queue a side-effect to fire after handler returns successfully */
154
+ tap(key: string, payload: unknown): void {
155
+ if (this._pendingTaps === null) {
156
+ this._pendingTaps = [{ key, payload }];
157
+ } else {
158
+ this._pendingTaps.push({ key, payload });
159
+ }
160
+ }
161
+
162
+ /* lazy getters on prototype — V8 hidden class optimized, no per-instance defineProperty */
163
+
164
+ static {
165
+ Object.defineProperty(HoneyContext.prototype, "cookies", {
166
+ configurable: true,
167
+ enumerable: true,
168
+ get(this: HoneyContext): Record<string, string> {
169
+ if (this._lzCookies === null) {
170
+ this._lzCookies = parseCookies(this.req.headers.get("cookie") ?? "");
171
+ }
172
+ return this._lzCookies;
173
+ },
174
+ });
175
+ Object.defineProperty(HoneyContext.prototype, "headers", {
176
+ configurable: true,
177
+ enumerable: true,
178
+ get(this: HoneyContext): Record<string, string> {
179
+ if (this._lzHeaders === null) {
180
+ const h: Record<string, string> = {};
181
+ this.req.headers.forEach((value, key) => {
182
+ h[key] = value;
183
+ });
184
+ this._lzHeaders = h;
185
+ }
186
+ return this._lzHeaders;
187
+ },
188
+ });
189
+ Object.defineProperty(HoneyContext.prototype, "search", {
190
+ configurable: true,
191
+ enumerable: true,
192
+ get(this: HoneyContext): Record<string, string> {
193
+ if (this._lzSearch === null) {
194
+ parseSearch(this);
195
+ }
196
+ return this._lzSearch as Record<string, string>;
197
+ },
198
+ });
199
+ Object.defineProperty(HoneyContext.prototype, "searchAll", {
200
+ configurable: true,
201
+ enumerable: true,
202
+ get(this: HoneyContext): Record<string, string[]> {
203
+ if (this._lzSearchAll === null) {
204
+ parseSearch(this);
205
+ }
206
+ return this._lzSearchAll as Record<string, string[]>;
207
+ },
208
+ });
209
+ }
210
+ }
@@ -0,0 +1,58 @@
1
+ const encoder = new TextEncoder()
2
+
3
+ function importKey(secret: string): Promise<CryptoKey> {
4
+ return crypto.subtle.importKey(
5
+ "raw",
6
+ encoder.encode(secret),
7
+ { hash: "SHA-256", name: "HMAC" },
8
+ false,
9
+ ["sign", "verify"],
10
+ )
11
+ }
12
+
13
+ function toBase64Url(buffer: ArrayBuffer): string {
14
+ const bytes = new Uint8Array(buffer)
15
+ let binary = ""
16
+ for (let i = 0; i < bytes.length; i++) {
17
+ binary += String.fromCharCode(bytes[i])
18
+ }
19
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
20
+ }
21
+
22
+ function fromBase64Url(str: string): Uint8Array {
23
+ const padded = str.replace(/-/g, "+").replace(/_/g, "/")
24
+ const binary = atob(padded)
25
+ const bytes = new Uint8Array(binary.length)
26
+ for (let i = 0; i < binary.length; i++) {
27
+ bytes[i] = binary.charCodeAt(i)
28
+ }
29
+ return bytes
30
+ }
31
+
32
+ export async function sign(value: string, secret: string): Promise<string> {
33
+ const key = await importKey(secret)
34
+ const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(value))
35
+ return `${value}.${toBase64Url(signature)}`
36
+ }
37
+
38
+ export async function verify(signed: string, secrets: string[]): Promise<string | null> {
39
+ if (signed.length === 0) return null
40
+ const lastDot = signed.lastIndexOf(".")
41
+ if (lastDot === -1) return null
42
+
43
+ const value = signed.slice(0, lastDot)
44
+ const sig = fromBase64Url(signed.slice(lastDot + 1))
45
+
46
+ for (const secret of secrets) {
47
+ const key = await importKey(secret)
48
+ const valid = await crypto.subtle.verify(
49
+ "HMAC",
50
+ key,
51
+ sig.buffer as ArrayBuffer,
52
+ encoder.encode(value),
53
+ )
54
+ if (valid) return value
55
+ }
56
+
57
+ return null
58
+ }
package/src/cookie.ts ADDED
@@ -0,0 +1,89 @@
1
+ export type CookieOptions = {
2
+ domain?: string
3
+ expires?: Date
4
+ httpOnly?: boolean
5
+ maxAge?: number
6
+ path?: string
7
+ sameSite?: "lax" | "none" | "strict"
8
+ secure?: boolean
9
+ value: string
10
+ }
11
+
12
+ /**
13
+ * Percent-encode characters outside the RFC 6265 cookie-octet range.
14
+ * Cookie-octets: 0x21, 0x23-0x2B, 0x2D-0x3A, 0x3C-0x5B, 0x5D-0x7E
15
+ */
16
+ function encodeCookieValue(value: string): string {
17
+ let encoded = ""
18
+ for (const ch of value) {
19
+ const c = ch.codePointAt(0) ?? 0
20
+ if (
21
+ c === 0x21 ||
22
+ (c >= 0x23 && c <= 0x2b) ||
23
+ (c >= 0x2d && c <= 0x3a) ||
24
+ (c >= 0x3c && c <= 0x5b) ||
25
+ (c >= 0x5d && c <= 0x7e)
26
+ ) {
27
+ encoded += ch
28
+ } else {
29
+ encoded += encodeURIComponent(ch)
30
+ }
31
+ }
32
+ return encoded
33
+ }
34
+
35
+ const validCookieNameRe = /^[\w!#$%&'*.^`|~+-]+$/
36
+
37
+ export function serializeCookie(name: string, opts: CookieOptions): string {
38
+ if (!validCookieNameRe.test(name)) {
39
+ throw new Error(`Invalid cookie name: ${JSON.stringify(name)}`)
40
+ }
41
+
42
+ let secure = opts.secure
43
+ let path = opts.path
44
+
45
+ if (name.startsWith("__Host-")) {
46
+ if (!opts.secure) throw new Error("__Host- cookies require secure: true")
47
+ if (opts.domain) throw new Error("__Host- cookies must not set domain")
48
+ if (opts.path !== "/" && opts.path !== undefined) {
49
+ throw new Error("__Host- cookies must have path: '/'")
50
+ }
51
+ secure = true
52
+ path = path ?? "/"
53
+ } else if (name.startsWith("__Secure-")) {
54
+ if (!opts.secure) throw new Error("__Secure- cookies require secure: true")
55
+ secure = true
56
+ }
57
+
58
+ if (opts.sameSite === "none" && !secure) {
59
+ throw new Error("SameSite=None cookies require secure: true")
60
+ }
61
+
62
+ if (opts.domain && /[;\r\n]/.test(opts.domain)) {
63
+ throw new Error("Cookie domain contains invalid characters")
64
+ }
65
+ if (path && /[;\r\n]/.test(path)) {
66
+ throw new Error("Cookie path contains invalid characters")
67
+ }
68
+
69
+ let cookie = `${name}=${encodeCookieValue(opts.value)}`
70
+ if (opts.domain) cookie += `; Domain=${opts.domain}`
71
+ if (path) cookie += `; Path=${path}`
72
+ if (opts.maxAge !== undefined) {
73
+ if (!Number.isFinite(opts.maxAge) || opts.maxAge < 0) {
74
+ throw new Error(`Invalid cookie Max-Age: ${opts.maxAge}`)
75
+ }
76
+ cookie += `; Max-Age=${Math.floor(opts.maxAge)}`
77
+ }
78
+ if (opts.expires) {
79
+ if (Number.isNaN(opts.expires.getTime())) {
80
+ throw new Error("Invalid cookie Expires: date is invalid")
81
+ }
82
+ cookie += `; Expires=${opts.expires.toUTCString()}`
83
+ }
84
+ if (opts.httpOnly) cookie += "; HttpOnly"
85
+ if (secure) cookie += "; Secure"
86
+ if (opts.sameSite)
87
+ cookie += `; SameSite=${opts.sameSite.charAt(0).toUpperCase()}${opts.sameSite.slice(1)}`
88
+ return cookie
89
+ }
package/src/cors.ts ADDED
@@ -0,0 +1,96 @@
1
+ import { replaceResponse } from "./honey-response.ts"
2
+ import type { MiddlewareFn } from "./middleware.ts"
3
+
4
+ export type CORSOptions = {
5
+ credentials?: boolean
6
+ exposeHeaders?: string[]
7
+ headers?: string[]
8
+ maxAge?: number
9
+ methods?: string[]
10
+ origin?: "*" | ((origin: string) => boolean) | string | string[]
11
+ }
12
+
13
+ const DEFAULT_METHODS = ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE"]
14
+ const DEFAULT_MAX_AGE = 86400
15
+
16
+ function matchOrigin(origin: string, config: CORSOptions["origin"]): string | null {
17
+ if (config === undefined || config === "*") return "*"
18
+ if (typeof config === "string") return config === origin ? origin : null
19
+ if (Array.isArray(config)) return config.includes(origin) ? origin : null
20
+ if (typeof config === "function") return config(origin) ? origin : null
21
+ return null
22
+ }
23
+
24
+ export function cors(options?: CORSOptions): MiddlewareFn<{ req: Request }, {}> {
25
+ const opts = options ?? {}
26
+
27
+ /* credentials + wildcard is a CORS spec violation — auto-narrow to echo origin */
28
+ const credentialWildcard =
29
+ opts.credentials === true && (opts.origin === undefined || opts.origin === "*")
30
+
31
+ const mw: MiddlewareFn<{ req: Request }, {}> = async (ctx, next) => {
32
+ const req = ctx.req
33
+ const requestOrigin = req.headers.get("origin")
34
+
35
+ /* no origin → not a CORS request */
36
+ if (requestOrigin === null) {
37
+ return next()
38
+ }
39
+
40
+ const allowedOrigin = credentialWildcard
41
+ ? requestOrigin
42
+ : matchOrigin(requestOrigin, opts.origin)
43
+
44
+ /* origin not allowed */
45
+ if (allowedOrigin === null) {
46
+ return next()
47
+ }
48
+
49
+ const isPreflight = req.method === "OPTIONS" && req.headers.has("access-control-request-method")
50
+
51
+ /* Vary: Origin whenever response depends on the request origin (non-wildcard) */
52
+ const needsVary = allowedOrigin !== "*"
53
+
54
+ if (isPreflight) {
55
+ const corsHeaders = new Headers()
56
+ corsHeaders.set("access-control-allow-origin", allowedOrigin)
57
+ corsHeaders.set("access-control-allow-methods", (opts.methods ?? DEFAULT_METHODS).join(", "))
58
+ if (opts.headers && opts.headers.length > 0) {
59
+ corsHeaders.set("access-control-allow-headers", opts.headers.join(", "))
60
+ } else {
61
+ const requested = req.headers.get("access-control-request-headers")
62
+ if (requested) {
63
+ corsHeaders.set("access-control-allow-headers", requested)
64
+ }
65
+ }
66
+ corsHeaders.set("access-control-max-age", String(opts.maxAge ?? DEFAULT_MAX_AGE))
67
+ if (opts.credentials) {
68
+ corsHeaders.set("access-control-allow-credentials", "true")
69
+ }
70
+ if (needsVary) {
71
+ corsHeaders.set("vary", "Origin")
72
+ }
73
+ return new Response(null, { headers: corsHeaders, status: 204 })
74
+ }
75
+
76
+ /* simple/actual request */
77
+ const response = await next()
78
+ const headers = new Headers(response.headers)
79
+ headers.set("access-control-allow-origin", allowedOrigin)
80
+ if (opts.credentials) {
81
+ headers.set("access-control-allow-credentials", "true")
82
+ }
83
+ if (opts.exposeHeaders && opts.exposeHeaders.length > 0) {
84
+ headers.set("access-control-expose-headers", opts.exposeHeaders.join(", "))
85
+ }
86
+ if (needsVary) {
87
+ headers.append("vary", "Origin")
88
+ }
89
+ return replaceResponse(response, {
90
+ headers,
91
+ status: response.status,
92
+ })
93
+ }
94
+
95
+ return mw
96
+ }
package/src/crypto.ts ADDED
@@ -0,0 +1,19 @@
1
+ const encoder = new TextEncoder()
2
+
3
+ let cachedKey: CryptoKey | null = null
4
+
5
+ async function getKey(): Promise<CryptoKey> {
6
+ if (cachedKey === null) {
7
+ cachedKey = await crypto.subtle.generateKey({ hash: "SHA-256", name: "HMAC" }, false, [
8
+ "sign",
9
+ "verify",
10
+ ])
11
+ }
12
+ return cachedKey
13
+ }
14
+
15
+ export async function timingSafeEqual(a: string, b: string): Promise<boolean> {
16
+ const key = await getKey()
17
+ const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(a))
18
+ return crypto.subtle.verify("HMAC", key, sig, encoder.encode(b))
19
+ }
package/src/csrf.ts ADDED
@@ -0,0 +1,54 @@
1
+ import { HoneyError } from "./error.ts"
2
+ import type { MiddlewareFn } from "./middleware.ts"
3
+ import { EK, SK } from "./types.ts"
4
+
5
+ type CSRFOptions = {
6
+ origin?: ((origin: string) => boolean) | string | string[]
7
+ }
8
+
9
+ const safeMethodRe = /^(GET|HEAD|OPTIONS)$/
10
+ const formContentTypeRe =
11
+ /^\b(application\/x-www-form-urlencoded|multipart\/form-data|text\/plain)\b/i
12
+
13
+ function matchOrigin(origin: string, config: CSRFOptions["origin"]): boolean {
14
+ if (config === undefined) return false
15
+ if (typeof config === "string") return config === origin
16
+ if (Array.isArray(config)) return config.includes(origin)
17
+ return config(origin)
18
+ }
19
+
20
+ export function csrf(options?: CSRFOptions): MiddlewareFn<{ req: Request }, {}> {
21
+ const opts = options ?? {}
22
+
23
+ const mw: MiddlewareFn<{ req: Request }, {}> = (ctx, next) => {
24
+ const req = ctx.req
25
+
26
+ if (safeMethodRe.test(req.method)) {
27
+ return next()
28
+ }
29
+
30
+ const contentType = req.headers.get("content-type")
31
+ if (contentType === null || !formContentTypeRe.test(contentType)) {
32
+ return next()
33
+ }
34
+
35
+ /* check Sec-Fetch-Site first (modern browsers, unforgeable) */
36
+ const secFetchSite = req.headers.get("sec-fetch-site")
37
+ if (secFetchSite === "same-origin") {
38
+ return next()
39
+ }
40
+
41
+ /* fall back to Origin header */
42
+ const origin = req.headers.get("origin")
43
+ if (origin !== null && matchOrigin(origin, opts.origin)) {
44
+ return next()
45
+ }
46
+
47
+ throw new HoneyError({
48
+ errorKey: EK.forbidden,
49
+ status: SK.forbidden,
50
+ })
51
+ }
52
+
53
+ return mw
54
+ }