@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
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import type { MiddlewareFn } from "./middleware.ts"
|
|
2
|
+
import type { LoggerInstance } from "./logger.ts"
|
|
3
|
+
|
|
4
|
+
type BodyOmittedReason =
|
|
5
|
+
| "content-type"
|
|
6
|
+
| "disabled"
|
|
7
|
+
| "missing"
|
|
8
|
+
| "read-error"
|
|
9
|
+
| "too-large"
|
|
10
|
+
|
|
11
|
+
type CurlLogData = {
|
|
12
|
+
bodyIncluded: boolean
|
|
13
|
+
bodyOmittedReason: BodyOmittedReason | null
|
|
14
|
+
curl: string
|
|
15
|
+
duration: number
|
|
16
|
+
method: string
|
|
17
|
+
path: string
|
|
18
|
+
requestId: string | null
|
|
19
|
+
status: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type CurlLoggerBodyOptions = {
|
|
23
|
+
allowContentTypes?: string[]
|
|
24
|
+
maxBytes?: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type CurlLoggerOptions = {
|
|
28
|
+
body?: boolean | CurlLoggerBodyOptions
|
|
29
|
+
instance?: LoggerInstance
|
|
30
|
+
log?: (data: CurlLogData) => void
|
|
31
|
+
redactHeader?: (name: string, value: string) => string | null
|
|
32
|
+
redactQueryParam?: (name: string, value: string) => string | null
|
|
33
|
+
skip?: (data: CurlLogData) => boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DEFAULT_BODY_CONTENT_TYPES = [
|
|
37
|
+
"application/json",
|
|
38
|
+
"application/x-www-form-urlencoded",
|
|
39
|
+
"application/xml",
|
|
40
|
+
"text/",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
const DEFAULT_MAX_BODY_BYTES = 16_384
|
|
44
|
+
|
|
45
|
+
function shellEscape(value: string): string {
|
|
46
|
+
return value.replace(/'/g, "'\\''")
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function defaultLog(data: CurlLogData): void {
|
|
50
|
+
console.log(data.curl)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function shouldIncludeBody(contentType: string | null, allowContentTypes: string[]): boolean {
|
|
54
|
+
if (contentType === null || contentType.length === 0) return false
|
|
55
|
+
return allowContentTypes.some((allowed) => contentType.startsWith(allowed))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function redactUrl(url: URL, redactQueryParam?: (name: string, value: string) => string | null): string {
|
|
59
|
+
if (redactQueryParam === undefined || url.search.length === 0) return url.toString()
|
|
60
|
+
|
|
61
|
+
const redacted = new URL(url.toString())
|
|
62
|
+
redacted.search = ""
|
|
63
|
+
|
|
64
|
+
for (const [name, value] of url.searchParams.entries()) {
|
|
65
|
+
const nextValue = redactQueryParam(name, value)
|
|
66
|
+
if (nextValue === null) continue
|
|
67
|
+
redacted.searchParams.append(name, nextValue)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return redacted.toString()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function readBodyWithinLimit(request: Request, maxBytes: number): Promise<{
|
|
74
|
+
body: string | null
|
|
75
|
+
omittedReason: BodyOmittedReason | null
|
|
76
|
+
}> {
|
|
77
|
+
if (request.body === null) {
|
|
78
|
+
return { body: null, omittedReason: "missing" }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const contentLength = request.headers.get("content-length")
|
|
82
|
+
if (contentLength !== null) {
|
|
83
|
+
const parsedLength = Number.parseInt(contentLength, 10)
|
|
84
|
+
if (!Number.isNaN(parsedLength) && parsedLength > maxBytes) {
|
|
85
|
+
return { body: null, omittedReason: "too-large" }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const clone = request.clone()
|
|
90
|
+
if (clone.body === null) {
|
|
91
|
+
return { body: null, omittedReason: "missing" }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const reader = clone.body.getReader()
|
|
95
|
+
const chunks: Uint8Array[] = []
|
|
96
|
+
let totalBytes = 0
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
while (true) {
|
|
100
|
+
const { done, value } = await reader.read()
|
|
101
|
+
if (done) break
|
|
102
|
+
totalBytes += value.byteLength
|
|
103
|
+
if (totalBytes > maxBytes) {
|
|
104
|
+
await reader.cancel()
|
|
105
|
+
return { body: null, omittedReason: "too-large" }
|
|
106
|
+
}
|
|
107
|
+
chunks.push(value)
|
|
108
|
+
}
|
|
109
|
+
} catch {
|
|
110
|
+
return { body: null, omittedReason: "read-error" }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const bytes = new Uint8Array(totalBytes)
|
|
114
|
+
let offset = 0
|
|
115
|
+
|
|
116
|
+
for (const chunk of chunks) {
|
|
117
|
+
bytes.set(chunk, offset)
|
|
118
|
+
offset += chunk.byteLength
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
body: new TextDecoder().decode(bytes),
|
|
123
|
+
omittedReason: null,
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function buildCurlLogData(
|
|
128
|
+
request: Request,
|
|
129
|
+
options?: Pick<CurlLoggerOptions, "body" | "redactHeader" | "redactQueryParam">,
|
|
130
|
+
): Promise<Pick<CurlLogData, "bodyIncluded" | "bodyOmittedReason" | "curl">> {
|
|
131
|
+
const parts: string[] = [`curl -X ${request.method}`]
|
|
132
|
+
|
|
133
|
+
for (const [name, value] of request.headers.entries()) {
|
|
134
|
+
const redactedValue = options?.redactHeader !== undefined
|
|
135
|
+
? options.redactHeader(name, value)
|
|
136
|
+
: value
|
|
137
|
+
if (redactedValue === null) continue
|
|
138
|
+
parts.push(`-H '${shellEscape(name)}: ${shellEscape(redactedValue)}'`)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let bodyIncluded = false
|
|
142
|
+
let bodyOmittedReason: BodyOmittedReason | null = null
|
|
143
|
+
|
|
144
|
+
if (options?.body === true || typeof options?.body === "object") {
|
|
145
|
+
const bodyOptions = typeof options.body === "object" ? options.body : {}
|
|
146
|
+
const allowContentTypes = bodyOptions.allowContentTypes ?? DEFAULT_BODY_CONTENT_TYPES
|
|
147
|
+
const contentType = request.headers.get("content-type")
|
|
148
|
+
|
|
149
|
+
if (shouldIncludeBody(contentType, allowContentTypes) === false) {
|
|
150
|
+
bodyOmittedReason = request.body === null ? "missing" : "content-type"
|
|
151
|
+
} else {
|
|
152
|
+
const result = await readBodyWithinLimit(
|
|
153
|
+
request,
|
|
154
|
+
bodyOptions.maxBytes ?? DEFAULT_MAX_BODY_BYTES,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
if (result.body === null) {
|
|
158
|
+
bodyOmittedReason = result.omittedReason
|
|
159
|
+
} else {
|
|
160
|
+
bodyIncluded = true
|
|
161
|
+
parts.push(`--data-raw '${shellEscape(result.body)}'`)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
} else {
|
|
165
|
+
bodyOmittedReason = "disabled"
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
parts.push(`'${shellEscape(redactUrl(new URL(request.url), options?.redactQueryParam))}'`)
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
bodyIncluded,
|
|
172
|
+
bodyOmittedReason,
|
|
173
|
+
curl: parts.join(" "),
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function curlLogger(
|
|
178
|
+
options?: CurlLoggerOptions,
|
|
179
|
+
): MiddlewareFn<{ path: string, req: Request }, {}> {
|
|
180
|
+
const log = options?.log ?? defaultLog
|
|
181
|
+
const skip = options?.skip
|
|
182
|
+
|
|
183
|
+
return async (ctx, next) => {
|
|
184
|
+
const start = performance.now()
|
|
185
|
+
const method = ctx.req.method
|
|
186
|
+
const path = ctx.path
|
|
187
|
+
const rid = (ctx as Record<string, unknown>)["requestId"] as string | null ?? null
|
|
188
|
+
const curlDataPromise = buildCurlLogData(ctx.req, options)
|
|
189
|
+
|
|
190
|
+
const response = await next()
|
|
191
|
+
const duration = performance.now() - start
|
|
192
|
+
const curlData = await curlDataPromise
|
|
193
|
+
|
|
194
|
+
const data: CurlLogData = {
|
|
195
|
+
...curlData,
|
|
196
|
+
duration,
|
|
197
|
+
method,
|
|
198
|
+
path,
|
|
199
|
+
requestId: rid,
|
|
200
|
+
status: response.status,
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (skip?.(data)) {
|
|
204
|
+
return response
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (options?.instance) {
|
|
208
|
+
options.instance.info(
|
|
209
|
+
{
|
|
210
|
+
bodyIncluded: data.bodyIncluded,
|
|
211
|
+
bodyOmittedReason: data.bodyOmittedReason,
|
|
212
|
+
curl: data.curl,
|
|
213
|
+
duration: data.duration,
|
|
214
|
+
method: data.method,
|
|
215
|
+
path: data.path,
|
|
216
|
+
requestId: data.requestId,
|
|
217
|
+
status: data.status,
|
|
218
|
+
},
|
|
219
|
+
"request curl",
|
|
220
|
+
)
|
|
221
|
+
} else {
|
|
222
|
+
log(data)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return response
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export { buildCurlLogData, curlLogger }
|
|
230
|
+
export type {
|
|
231
|
+
BodyOmittedReason,
|
|
232
|
+
CurlLogData,
|
|
233
|
+
CurlLoggerBodyOptions,
|
|
234
|
+
CurlLoggerOptions,
|
|
235
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type ServeRuntime = "bun" | "cloudflare" | "deno" | "node"
|
|
2
|
+
|
|
3
|
+
type DetectGlobal = {
|
|
4
|
+
Bun?: unknown
|
|
5
|
+
Deno?: unknown
|
|
6
|
+
navigator?: { userAgent?: string }
|
|
7
|
+
process?: { versions?: { node?: string } }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function detectRuntime(g: DetectGlobal = globalThis): ServeRuntime {
|
|
11
|
+
if (typeof g.Bun !== "undefined") return "bun"
|
|
12
|
+
if (typeof g.Deno !== "undefined") return "deno"
|
|
13
|
+
const ua = g.navigator?.userAgent ?? ""
|
|
14
|
+
if (ua.includes("Cloudflare-Workers") || ua.includes("workerd")) return "cloudflare"
|
|
15
|
+
if (g.process?.versions?.node) return "node"
|
|
16
|
+
throw new Error(
|
|
17
|
+
'Honey.serve() could not detect a runtime. Pass runtime: "bun" | "node" | "deno".',
|
|
18
|
+
)
|
|
19
|
+
}
|
package/src/error.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { FieldError, StatusKey } from "./types.ts"
|
|
2
|
+
import { EMPTY_FIELDS, statusKeyToCode } from "./types.ts"
|
|
3
|
+
|
|
4
|
+
export class HoneyError extends Error {
|
|
5
|
+
readonly data?: unknown
|
|
6
|
+
readonly errorKey: string
|
|
7
|
+
readonly fields: Record<string, FieldError[]>
|
|
8
|
+
readonly headers?: Record<string, string>
|
|
9
|
+
readonly status: number
|
|
10
|
+
readonly statusKey: StatusKey
|
|
11
|
+
readonly vars?: Record<string, string | number>
|
|
12
|
+
|
|
13
|
+
constructor(opts: {
|
|
14
|
+
cause?: unknown
|
|
15
|
+
data?: unknown
|
|
16
|
+
errorKey: string
|
|
17
|
+
fields?: Record<string, FieldError[]>
|
|
18
|
+
headers?: Record<string, string>
|
|
19
|
+
status: StatusKey
|
|
20
|
+
vars?: Record<string, string | number>
|
|
21
|
+
}) {
|
|
22
|
+
super(opts.errorKey, opts.cause !== undefined ? { cause: opts.cause } : undefined)
|
|
23
|
+
this.data = opts.data
|
|
24
|
+
this.errorKey = opts.errorKey
|
|
25
|
+
this.headers = opts.headers
|
|
26
|
+
this.statusKey = opts.status
|
|
27
|
+
this.status = statusKeyToCode[opts.status]
|
|
28
|
+
this.fields = opts.fields ?? EMPTY_FIELDS
|
|
29
|
+
this.vars = opts.vars
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
static serialize(e: unknown): Record<string, unknown> {
|
|
33
|
+
if (!(e instanceof Error)) return { message: String(e) }
|
|
34
|
+
|
|
35
|
+
const obj: Record<string, unknown> = {
|
|
36
|
+
message: e.message,
|
|
37
|
+
name: e.name,
|
|
38
|
+
stack: e.stack,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (e instanceof HoneyError) {
|
|
42
|
+
obj["errorKey"] = e.errorKey
|
|
43
|
+
obj["status"] = e.status
|
|
44
|
+
obj["statusKey"] = e.statusKey
|
|
45
|
+
if (Object.keys(e.fields).length > 0) obj["fields"] = e.fields
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (e.cause) obj["cause"] = HoneyError.serialize(e.cause)
|
|
49
|
+
|
|
50
|
+
return obj
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { HoneyError } from "./error.ts"
|
|
2
|
+
import type { ExtractICUVars } from "./i18n.ts"
|
|
3
|
+
import type { FieldError, InferOutput, StandardSchemaLike, StatusKey } from "./types.ts"
|
|
4
|
+
|
|
5
|
+
type ErrorFactoryOpts<TVars> = {
|
|
6
|
+
cause?: unknown
|
|
7
|
+
fields?: Record<string, FieldError[]>
|
|
8
|
+
headers?: Record<string, string>
|
|
9
|
+
vars?: TVars
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type ErrorDefWithSchema = {
|
|
13
|
+
schema: StandardSchemaLike
|
|
14
|
+
status: StatusKey
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type ErrorDef = ErrorDefWithSchema | StatusKey
|
|
18
|
+
|
|
19
|
+
export type ErrorMetaEntry = {
|
|
20
|
+
schema: StandardSchemaLike | null
|
|
21
|
+
statusKey: StatusKey
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Per-key meta — keeps schema + statusKey literals for ComputeErrorsByStatus */
|
|
25
|
+
type ErrorMetaForDef<D extends ErrorDef> = D extends ErrorDefWithSchema
|
|
26
|
+
? { schema: D["schema"]; statusKey: D["status"] }
|
|
27
|
+
: D extends StatusKey
|
|
28
|
+
? { schema: null; statusKey: D }
|
|
29
|
+
: ErrorMetaEntry
|
|
30
|
+
|
|
31
|
+
export const ERROR_META: unique symbol = Symbol.for("honey.error.meta")
|
|
32
|
+
|
|
33
|
+
type ErrorFactoryResult<TMap extends Record<string, ErrorDef>, TTranslations extends Record<string, string>> = {
|
|
34
|
+
[K in keyof TMap]: TMap[K] extends ErrorDefWithSchema
|
|
35
|
+
? (data: InferOutput<TMap[K]["schema"]>, opts?: { cause?: unknown; headers?: Record<string, string> }) => HoneyError
|
|
36
|
+
: K extends keyof TTranslations
|
|
37
|
+
? (opts?: ErrorFactoryOpts<ExtractICUVars<TTranslations[K]>>) => HoneyError
|
|
38
|
+
: (opts?: ErrorFactoryOpts<Record<string, string | number>>) => HoneyError
|
|
39
|
+
} & {
|
|
40
|
+
readonly [ERROR_META]: { [K in keyof TMap]: ErrorMetaForDef<TMap[K]> }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function defineErrors<
|
|
44
|
+
TMap extends Record<string, ErrorDef>,
|
|
45
|
+
TTranslations extends Record<string, string> = Record<string, string>,
|
|
46
|
+
>(
|
|
47
|
+
map: TMap,
|
|
48
|
+
): ErrorFactoryResult<TMap, TTranslations> {
|
|
49
|
+
const result = Object.create(null) as Record<string, unknown>
|
|
50
|
+
const meta = Object.create(null) as Record<string, ErrorMetaEntry>
|
|
51
|
+
|
|
52
|
+
for (const [key, def] of Object.entries(map)) {
|
|
53
|
+
if (typeof def === "string") {
|
|
54
|
+
/* standard error — string status key */
|
|
55
|
+
meta[key] = { schema: null, statusKey: def }
|
|
56
|
+
result[key] = (opts?: ErrorFactoryOpts<Record<string, string | number>>) =>
|
|
57
|
+
new HoneyError({
|
|
58
|
+
cause: opts?.cause,
|
|
59
|
+
errorKey: key,
|
|
60
|
+
fields: opts?.fields,
|
|
61
|
+
headers: opts?.headers,
|
|
62
|
+
status: def,
|
|
63
|
+
vars: opts?.vars,
|
|
64
|
+
})
|
|
65
|
+
} else {
|
|
66
|
+
/* custom schema error — { status, schema } */
|
|
67
|
+
meta[key] = { schema: def.schema, statusKey: def.status }
|
|
68
|
+
result[key] = (data: unknown, opts?: { cause?: unknown; headers?: Record<string, string> }) =>
|
|
69
|
+
new HoneyError({
|
|
70
|
+
cause: opts?.cause,
|
|
71
|
+
data,
|
|
72
|
+
errorKey: key,
|
|
73
|
+
headers: opts?.headers,
|
|
74
|
+
status: def.status,
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
Object.defineProperty(result, ERROR_META, {
|
|
80
|
+
configurable: false,
|
|
81
|
+
enumerable: false,
|
|
82
|
+
value: meta,
|
|
83
|
+
writable: false,
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
return result as ErrorFactoryResult<TMap, TTranslations>
|
|
87
|
+
}
|
package/src/etag.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { replaceResponse } from "./honey-response.ts"
|
|
2
|
+
import type { MiddlewareFn } from "./middleware.ts"
|
|
3
|
+
|
|
4
|
+
type ETagOptions = {
|
|
5
|
+
weak?: boolean
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* FNV-1a 32-bit hash — fast, non-cryptographic, deterministic.
|
|
10
|
+
* Good enough for ETag fingerprinting (not security-critical).
|
|
11
|
+
*/
|
|
12
|
+
function fnv1a(data: Uint8Array): string {
|
|
13
|
+
let hash = 0x811c9dc5
|
|
14
|
+
for (let i = 0; i < data.length; i++) {
|
|
15
|
+
hash ^= data[i]
|
|
16
|
+
hash = Math.imul(hash, 0x01000193)
|
|
17
|
+
}
|
|
18
|
+
return (hash >>> 0).toString(16).padStart(8, "0")
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function etag(options?: ETagOptions): MiddlewareFn<{ req: Request }, {}> {
|
|
22
|
+
const weak = options?.weak !== false
|
|
23
|
+
|
|
24
|
+
const mw: MiddlewareFn<{ req: Request }, {}> = async (ctx, next) => {
|
|
25
|
+
const method = ctx.req.method
|
|
26
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
27
|
+
return next()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const response = await next()
|
|
31
|
+
|
|
32
|
+
/* skip error responses — caching errors is wasteful and misleading */
|
|
33
|
+
if (response.status >= 400) return response
|
|
34
|
+
|
|
35
|
+
/* skip streaming responses — can't hash without buffering entire stream */
|
|
36
|
+
if (
|
|
37
|
+
response.headers.get("transfer-encoding") === "chunked" ||
|
|
38
|
+
(response.body !== null &&
|
|
39
|
+
!response.headers.has("content-length") &&
|
|
40
|
+
!response.headers.has("content-type"))
|
|
41
|
+
) {
|
|
42
|
+
return response
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* read body to compute hash */
|
|
46
|
+
const body = await response.arrayBuffer()
|
|
47
|
+
if (body.byteLength === 0) return response
|
|
48
|
+
|
|
49
|
+
const hash = fnv1a(new Uint8Array(body))
|
|
50
|
+
const etagValue = weak ? `W/"${hash}"` : `"${hash}"`
|
|
51
|
+
|
|
52
|
+
const headers = new Headers(response.headers)
|
|
53
|
+
headers.set("etag", etagValue)
|
|
54
|
+
|
|
55
|
+
/* check If-None-Match — supports comma-separated list and wildcard per RFC 7232 */
|
|
56
|
+
const ifNoneMatch = ctx.req.headers.get("if-none-match")
|
|
57
|
+
if (ifNoneMatch === "*" || ifNoneMatch?.split(",").some((v) => v.trim() === etagValue)) {
|
|
58
|
+
return replaceResponse(response, {
|
|
59
|
+
body: null,
|
|
60
|
+
headers,
|
|
61
|
+
status: 304,
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/* return response with ETag header and reconstituted body */
|
|
66
|
+
return replaceResponse(response, {
|
|
67
|
+
body,
|
|
68
|
+
headers,
|
|
69
|
+
status: response.status,
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return mw
|
|
74
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { getI18nRuntime } from "./i18n-slot.ts"
|
|
2
|
+
import { getOpenApiRuntime } from "./openapi/spec-factory.ts"
|
|
3
|
+
import { getServeRuntime } from "./serve-slot.ts"
|
|
4
|
+
|
|
5
|
+
function isReady(name: "i18n" | "openapi" | "serve"): boolean {
|
|
6
|
+
try {
|
|
7
|
+
if (name === "i18n") getI18nRuntime()
|
|
8
|
+
else if (name === "openapi") getOpenApiRuntime()
|
|
9
|
+
else getServeRuntime()
|
|
10
|
+
return true
|
|
11
|
+
} catch {
|
|
12
|
+
return false
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Opaque so bun/esbuild do not follow unused feature entries into a fetch-only bundle. */
|
|
17
|
+
export async function loadHoneyFeature(name: "i18n" | "openapi" | "serve"): Promise<void> {
|
|
18
|
+
if (isReady(name)) return
|
|
19
|
+
const mod = (await import(["@lovrozagar/honey", name].join("/"))) as Record<string, unknown>
|
|
20
|
+
const enable = { i18n: "enableI18n", openapi: "enableOpenApi", serve: "enableServe" }[name]
|
|
21
|
+
const fn = mod[enable]
|
|
22
|
+
if (typeof fn === "function") fn()
|
|
23
|
+
}
|