@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.
- package/README.md +51 -0
- package/docs/api-ref/go.md +399 -0
- package/docs/api-ref/python.md +399 -0
- package/docs/api-ref/rust.md +399 -0
- package/docs/api-ref/typescript.md +399 -0
- package/docs/invalidate-spec.md +303 -0
- package/docs/sdk.md +722 -0
- package/package.json +120 -0
- package/src/accepts.ts +78 -0
- package/src/body-limit.ts +100 -0
- package/src/build/index.ts +174 -0
- package/src/cli-go/auth.go +15 -0
- package/src/cli-go/config.go +165 -0
- package/src/cli-go/errors.go +57 -0
- package/src/cli-go/multipart.go +55 -0
- package/src/cli-go/output.go +109 -0
- package/src/cli-go/root.go +30 -0
- package/src/cli-go/stream.go +88 -0
- package/src/cli-go/version.go +5 -0
- package/src/cli.ts +204 -0
- package/src/client/error.ts +27 -0
- package/src/client/http.ts +502 -0
- package/src/client/index.ts +127 -0
- package/src/client/sdk.ts +265 -0
- package/src/client/sse.ts +102 -0
- package/src/client/types.ts +211 -0
- package/src/client/ws.ts +115 -0
- package/src/client-go/errors.go +207 -0
- package/src/client-go/invalidation.go +373 -0
- package/src/client-go/realtime.go +317 -0
- package/src/client-go/result.go +20 -0
- package/src/client-go/runtime.go +413 -0
- package/src/client-go/sse.go +149 -0
- package/src/client-go/transport.go +452 -0
- package/src/client-go/ws.go +104 -0
- package/src/client-mcp/README.md +10 -0
- package/src/client-mcp/server.ts +94 -0
- package/src/client-mcp/types.ts +19 -0
- package/src/client-python/_errors.py +115 -0
- package/src/client-python/_invalidation.py +578 -0
- package/src/client-python/_realtime.py +156 -0
- package/src/client-python/_runtime.py +469 -0
- package/src/client-python/_sse.py +99 -0
- package/src/client-python/_transport.py +335 -0
- package/src/client-python/_ws.py +131 -0
- package/src/client-python/py.typed +0 -0
- package/src/client-rust/errors.rs +200 -0
- package/src/client-rust/invalidation.rs +441 -0
- package/src/client-rust/invalidation_sync.rs +306 -0
- package/src/client-rust/realtime.rs +695 -0
- package/src/client-rust/result.rs +22 -0
- package/src/client-rust/runtime.rs +547 -0
- package/src/client-rust/runtime_sync.rs +452 -0
- package/src/client-rust/sse.rs +167 -0
- package/src/client-rust/ws.rs +143 -0
- package/src/cloudflare/realtime.ts +236 -0
- package/src/codegen-go-cli.ts +1180 -0
- package/src/codegen-go.ts +1010 -0
- package/src/codegen-hash.ts +8 -0
- package/src/codegen-ir.ts +517 -0
- package/src/codegen-loaders.ts +53 -0
- package/src/codegen-mcp.ts +364 -0
- package/src/codegen-python.ts +1028 -0
- package/src/codegen-rust.ts +1652 -0
- package/src/codegen-sanitize.ts +67 -0
- package/src/codegen-schema-naming.ts +146 -0
- package/src/codegen.ts +5015 -0
- package/src/context.ts +210 -0
- package/src/cookie-sign.ts +58 -0
- package/src/cookie.ts +89 -0
- package/src/cors.ts +96 -0
- package/src/crypto.ts +19 -0
- package/src/csrf.ts +54 -0
- package/src/curl-logger.ts +235 -0
- package/src/detect-runtime.ts +19 -0
- package/src/error.ts +52 -0
- package/src/errors.ts +87 -0
- package/src/etag.ts +74 -0
- package/src/feature-load.ts +23 -0
- package/src/go-type-emitter.ts +404 -0
- package/src/honey-response.ts +354 -0
- package/src/i18n-slot.ts +20 -0
- package/src/i18n.ts +234 -0
- package/src/index.ts +3843 -0
- package/src/init.ts +149 -0
- package/src/input.ts +8 -0
- package/src/ip-restrict.ts +192 -0
- package/src/logger.ts +149 -0
- package/src/middleware.ts +199 -0
- package/src/node-request.ts +240 -0
- package/src/node.ts +212 -0
- package/src/openapi/register.ts +19 -0
- package/src/openapi/scalar.ts +33 -0
- package/src/openapi/spec-factory.ts +46 -0
- package/src/openapi/spec.ts +31 -0
- package/src/openapi/swagger.ts +34 -0
- package/src/plugin.ts +771 -0
- package/src/powered-by.ts +17 -0
- package/src/pretty-json.ts +39 -0
- package/src/proxy.ts +155 -0
- package/src/python-type-emitter.ts +144 -0
- package/src/realtime/buffer.ts +101 -0
- package/src/realtime/bus.ts +93 -0
- package/src/realtime/longpoll.ts +148 -0
- package/src/realtime/protocol.ts +128 -0
- package/src/realtime/route.ts +104 -0
- package/src/realtime/runtime.ts +430 -0
- package/src/request-id.ts +23 -0
- package/src/request-to-curl.ts +34 -0
- package/src/response.ts +414 -0
- package/src/rust-type-emitter.ts +412 -0
- package/src/secure-headers.ts +69 -0
- package/src/serve-register.ts +8 -0
- package/src/serve-slot.ts +20 -0
- package/src/serve.ts +141 -0
- package/src/server-timing.ts +66 -0
- package/src/static.ts +60 -0
- package/src/telemetry/otel.ts +139 -0
- package/src/testing.ts +114 -0
- package/src/timeout.ts +36 -0
- package/src/tree.ts +464 -0
- package/src/ts-type-emitter.ts +106 -0
- package/src/type-emitter.ts +524 -0
- package/src/type-extractor.ts +749 -0
- package/src/types.ts +638 -0
- package/src/validation.ts +300 -0
- package/src/ws/bun.ts +89 -0
- package/src/ws/cloudflare.ts +133 -0
- package/src/ws/deno.ts +109 -0
- package/src/ws/node.ts +161 -0
- 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
|
+
}
|