@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/init.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { basename, dirname, join } from "node:path"
|
|
3
|
+
import { fileURLToPath } from "node:url"
|
|
4
|
+
|
|
5
|
+
export type InitFlags = {
|
|
6
|
+
cf: boolean
|
|
7
|
+
force: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
type PackageJson = {
|
|
11
|
+
dependencies?: Record<string, string>
|
|
12
|
+
name?: string
|
|
13
|
+
private?: boolean
|
|
14
|
+
scripts?: Record<string, string>
|
|
15
|
+
type?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const APP_TS = `import { honey } from "@lovrozagar/honey"
|
|
19
|
+
|
|
20
|
+
export const app = honey()
|
|
21
|
+
.get("/health")
|
|
22
|
+
.handler((ctx) => ctx.res.text("ok", "ok"))
|
|
23
|
+
.openapi({ docs: "scalar", title: "Honey", version: "0.0.1" })
|
|
24
|
+
`
|
|
25
|
+
|
|
26
|
+
const SERVER_TS = `import { app } from "./app.ts"
|
|
27
|
+
|
|
28
|
+
const port = Number(process.env.PORT ?? 3000)
|
|
29
|
+
await app.serve({ port })
|
|
30
|
+
`
|
|
31
|
+
|
|
32
|
+
const VITE_CONFIG_TS = `import { honey } from "@lovrozagar/honey/plugin"
|
|
33
|
+
|
|
34
|
+
export default {
|
|
35
|
+
plugins: [
|
|
36
|
+
honey({
|
|
37
|
+
app: "src/app.ts",
|
|
38
|
+
}),
|
|
39
|
+
],
|
|
40
|
+
}
|
|
41
|
+
`
|
|
42
|
+
|
|
43
|
+
const WORKER_TS = `import { cfWebSocket } from "@lovrozagar/honey/ws/cloudflare"
|
|
44
|
+
import { app } from "./app.ts"
|
|
45
|
+
|
|
46
|
+
app.wsAdapter(cfWebSocket())
|
|
47
|
+
|
|
48
|
+
export default {
|
|
49
|
+
fetch: (req: Request, env: Record<string, unknown>, ctx: unknown) => app.fetch(req, env, ctx),
|
|
50
|
+
}
|
|
51
|
+
`
|
|
52
|
+
|
|
53
|
+
export function parseInitFlags(args: string[]): InitFlags {
|
|
54
|
+
return {
|
|
55
|
+
cf: args.includes("--cf"),
|
|
56
|
+
force: args.includes("--force"),
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function runInit(cwd: string, flags: InitFlags): void {
|
|
61
|
+
const files: Record<string, string> = {
|
|
62
|
+
"src/app.ts": APP_TS,
|
|
63
|
+
"src/server.ts": SERVER_TS,
|
|
64
|
+
"vite.config.ts": VITE_CONFIG_TS,
|
|
65
|
+
}
|
|
66
|
+
if (flags.cf) {
|
|
67
|
+
files["src/worker.ts"] = WORKER_TS
|
|
68
|
+
files["wrangler.jsonc"] = wranglerJsonc(packageNameFromDir(cwd))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const collisions = Object.keys(files).filter((rel) => existsSync(join(cwd, rel)))
|
|
72
|
+
if (collisions.length > 0 && !flags.force) {
|
|
73
|
+
throw new Error(`${collisions[0]} already exists. Use --force to overwrite.`)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
for (const [rel, contents] of Object.entries(files)) {
|
|
77
|
+
writeText(join(cwd, rel), contents)
|
|
78
|
+
}
|
|
79
|
+
writePackageJson(cwd)
|
|
80
|
+
console.log("honey: initialized")
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function wranglerJsonc(name: string): string {
|
|
84
|
+
return `{
|
|
85
|
+
"compatibility_date": "2026-01-20",
|
|
86
|
+
"compatibility_flags": ["nodejs_compat"],
|
|
87
|
+
"main": "src/worker.ts",
|
|
88
|
+
"name": ${JSON.stringify(name)},
|
|
89
|
+
"workers_dev": true
|
|
90
|
+
}
|
|
91
|
+
`
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function writePackageJson(cwd: string): void {
|
|
95
|
+
const path = join(cwd, "package.json")
|
|
96
|
+
const existing = existsSync(path) ? readPackageJson(path) : {}
|
|
97
|
+
const next: PackageJson = {
|
|
98
|
+
...existing,
|
|
99
|
+
name: existing.name ?? packageNameFromDir(cwd),
|
|
100
|
+
private: existing.private ?? true,
|
|
101
|
+
type: existing.type ?? "module",
|
|
102
|
+
scripts: {
|
|
103
|
+
...existing.scripts,
|
|
104
|
+
dev: "bun --watch src/server.ts",
|
|
105
|
+
generate: "honey generate",
|
|
106
|
+
},
|
|
107
|
+
dependencies: {
|
|
108
|
+
...existing.dependencies,
|
|
109
|
+
honey: existing.dependencies?.honey ?? `^${honeyVersion()}`,
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
writeText(path, `${JSON.stringify(next, null, "\t")}\n`)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function readPackageJson(path: string): PackageJson {
|
|
116
|
+
let parsed: unknown
|
|
117
|
+
try {
|
|
118
|
+
parsed = JSON.parse(readFileSync(path, "utf-8"))
|
|
119
|
+
} catch {
|
|
120
|
+
throw new Error(`package.json is not valid JSON`)
|
|
121
|
+
}
|
|
122
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
123
|
+
throw new Error("package.json must be an object")
|
|
124
|
+
}
|
|
125
|
+
return parsed as PackageJson
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function writeText(path: string, contents: string): void {
|
|
129
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
130
|
+
writeFileSync(path, contents.endsWith("\n") ? contents : `${contents}\n`, "utf-8")
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function packageNameFromDir(dir: string): string {
|
|
134
|
+
const base = basename(dir)
|
|
135
|
+
.toLowerCase()
|
|
136
|
+
.replace(/[^a-z0-9._-]+/g, "-")
|
|
137
|
+
.replace(/^[-.]+|[-.]+$/g, "")
|
|
138
|
+
return base || "honey-app"
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function honeyVersion(): string {
|
|
142
|
+
try {
|
|
143
|
+
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json")
|
|
144
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { version?: string }
|
|
145
|
+
return typeof pkg.version === "string" ? pkg.version : "0.0.1"
|
|
146
|
+
} catch {
|
|
147
|
+
return "0.0.1"
|
|
148
|
+
}
|
|
149
|
+
}
|
package/src/input.ts
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { HoneyError } from "./error.ts"
|
|
2
|
+
import type { MiddlewareFn } from "./middleware.ts"
|
|
3
|
+
import { EK, SK } from "./types.ts"
|
|
4
|
+
|
|
5
|
+
type IpRestrictOptions = {
|
|
6
|
+
allowList?: string[]
|
|
7
|
+
denyList?: string[]
|
|
8
|
+
getIp?: (req: Request) => string | null
|
|
9
|
+
trustProxy?: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function stripBrackets(ip: string): string {
|
|
13
|
+
if (ip.startsWith("[") && ip.endsWith("]")) {
|
|
14
|
+
return ip.slice(1, -1)
|
|
15
|
+
}
|
|
16
|
+
return ip
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function defaultGetIp(req: Request): string | null {
|
|
20
|
+
const cfIp = req.headers.get("cf-connecting-ip")
|
|
21
|
+
if (cfIp) return stripBrackets(cfIp.trim())
|
|
22
|
+
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function proxyAwareGetIp(req: Request): string | null {
|
|
27
|
+
const cfIp = req.headers.get("cf-connecting-ip")
|
|
28
|
+
if (cfIp) return stripBrackets(cfIp.trim())
|
|
29
|
+
|
|
30
|
+
const xff = req.headers.get("x-forwarded-for")
|
|
31
|
+
if (xff) return stripBrackets(xff.split(",")[0].trim())
|
|
32
|
+
|
|
33
|
+
const realIp = req.headers.get("x-real-ip")
|
|
34
|
+
if (realIp) return stripBrackets(realIp.trim())
|
|
35
|
+
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function parseIpv4(ip: string): number | null {
|
|
40
|
+
const parts = ip.split(".")
|
|
41
|
+
if (parts.length !== 4) return null
|
|
42
|
+
let result = 0
|
|
43
|
+
for (const part of parts) {
|
|
44
|
+
const n = Number.parseInt(part, 10)
|
|
45
|
+
if (Number.isNaN(n) || n < 0 || n > 255) return null
|
|
46
|
+
result = (result << 8) | n
|
|
47
|
+
}
|
|
48
|
+
return result >>> 0
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type Ipv4CidrRule = {
|
|
52
|
+
base: number
|
|
53
|
+
mask: number
|
|
54
|
+
v6: false
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type Ipv6CidrRule = {
|
|
58
|
+
base: bigint
|
|
59
|
+
mask: bigint
|
|
60
|
+
v6: true
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
type CidrRule = Ipv4CidrRule | Ipv6CidrRule
|
|
64
|
+
|
|
65
|
+
type ParsedRule = {
|
|
66
|
+
cidr?: CidrRule
|
|
67
|
+
exact: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function expandIpv6(ip: string): bigint | null {
|
|
71
|
+
let addr = ip
|
|
72
|
+
|
|
73
|
+
/* handle ::ffff:1.2.3.4 mapped IPv4 */
|
|
74
|
+
const v4Suffix = addr.match(/:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/)
|
|
75
|
+
if (v4Suffix) {
|
|
76
|
+
const v4 = parseIpv4(v4Suffix[1])
|
|
77
|
+
if (v4 === null) return null
|
|
78
|
+
addr = `${addr.slice(0, addr.lastIndexOf(":"))}:${((v4 >>> 16) & 0xffff).toString(16)}:${(v4 & 0xffff).toString(16)}`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* expand :: */
|
|
82
|
+
const halves = addr.split("::")
|
|
83
|
+
if (halves.length > 2) return null
|
|
84
|
+
|
|
85
|
+
let groups: string[]
|
|
86
|
+
if (halves.length === 2) {
|
|
87
|
+
const left = halves[0] === "" ? [] : halves[0].split(":")
|
|
88
|
+
const right = halves[1] === "" ? [] : halves[1].split(":")
|
|
89
|
+
const fill = 8 - left.length - right.length
|
|
90
|
+
if (fill < 0) return null
|
|
91
|
+
groups = [...left, ...Array.from<string>({ length: fill }).fill("0"), ...right]
|
|
92
|
+
} else {
|
|
93
|
+
groups = addr.split(":")
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (groups.length !== 8) return null
|
|
97
|
+
|
|
98
|
+
let result = 0n
|
|
99
|
+
for (const g of groups) {
|
|
100
|
+
const n = Number.parseInt(g, 16)
|
|
101
|
+
if (Number.isNaN(n) || n < 0 || n > 0xffff) return null
|
|
102
|
+
result = (result << 16n) | BigInt(n)
|
|
103
|
+
}
|
|
104
|
+
return result
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isIpv6(ip: string): boolean {
|
|
108
|
+
return ip.includes(":")
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function parseRule(rule: string): ParsedRule {
|
|
112
|
+
const slashIdx = rule.indexOf("/")
|
|
113
|
+
if (slashIdx === -1) {
|
|
114
|
+
return { exact: rule }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const ip = rule.slice(0, slashIdx)
|
|
118
|
+
const bits = Number.parseInt(rule.slice(slashIdx + 1), 10)
|
|
119
|
+
|
|
120
|
+
if (isIpv6(ip)) {
|
|
121
|
+
const base = expandIpv6(ip)
|
|
122
|
+
if (base === null || Number.isNaN(bits) || bits < 0 || bits > 128) {
|
|
123
|
+
return { exact: rule }
|
|
124
|
+
}
|
|
125
|
+
const mask = bits === 0 ? 0n : ((1n << BigInt(bits)) - 1n) << BigInt(128 - bits)
|
|
126
|
+
return { cidr: { base: base & mask, mask, v6: true }, exact: rule }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const base = parseIpv4(ip)
|
|
130
|
+
if (base === null || Number.isNaN(bits) || bits < 0 || bits > 32) {
|
|
131
|
+
return { exact: rule }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0
|
|
135
|
+
return { cidr: { base: (base & mask) >>> 0, mask, v6: false }, exact: rule }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function matchesRule(ip: string, rule: ParsedRule): boolean {
|
|
139
|
+
if (rule.cidr) {
|
|
140
|
+
if (rule.cidr.v6) {
|
|
141
|
+
const parsed = expandIpv6(ip)
|
|
142
|
+
if (parsed === null) return false
|
|
143
|
+
return (parsed & rule.cidr.mask) === rule.cidr.base
|
|
144
|
+
}
|
|
145
|
+
const parsed = parseIpv4(ip)
|
|
146
|
+
if (parsed === null) return false
|
|
147
|
+
return (parsed & rule.cidr.mask) >>> 0 === rule.cidr.base
|
|
148
|
+
}
|
|
149
|
+
return ip === rule.exact
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function throwForbidden(): never {
|
|
153
|
+
throw new HoneyError({
|
|
154
|
+
errorKey: EK.forbidden,
|
|
155
|
+
status: SK.forbidden,
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function ipRestrict(opts: IpRestrictOptions): MiddlewareFn<{ req: Request }, {}> {
|
|
160
|
+
const getIp = opts.getIp ?? (opts.trustProxy === true ? proxyAwareGetIp : defaultGetIp)
|
|
161
|
+
const denyRules = opts.denyList?.map(parseRule) ?? []
|
|
162
|
+
const allowRules = opts.allowList?.map(parseRule) ?? []
|
|
163
|
+
const hasAllowList = allowRules.length > 0
|
|
164
|
+
|
|
165
|
+
const mw: MiddlewareFn<{ req: Request }, {}> = (ctx, next) => {
|
|
166
|
+
const ip = getIp(ctx.req)
|
|
167
|
+
|
|
168
|
+
/* deny list checked first */
|
|
169
|
+
if (ip !== null) {
|
|
170
|
+
for (const rule of denyRules) {
|
|
171
|
+
if (matchesRule(ip, rule)) throwForbidden()
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/* allow list: if set, IP must match at least one rule */
|
|
176
|
+
if (hasAllowList) {
|
|
177
|
+
if (ip === null) throwForbidden()
|
|
178
|
+
let allowed = false
|
|
179
|
+
for (const rule of allowRules) {
|
|
180
|
+
if (matchesRule(ip, rule)) {
|
|
181
|
+
allowed = true
|
|
182
|
+
break
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (!allowed) throwForbidden()
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return next()
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return mw
|
|
192
|
+
}
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { MiddlewareFn } from "./middleware.ts"
|
|
2
|
+
|
|
3
|
+
/* ── log levels (pino-compatible numeric values) ── */
|
|
4
|
+
|
|
5
|
+
type LogLevel = "debug" | "error" | "fatal" | "info" | "trace" | "warn"
|
|
6
|
+
|
|
7
|
+
const LEVELS: Record<LogLevel, number> = {
|
|
8
|
+
debug: 20,
|
|
9
|
+
error: 50,
|
|
10
|
+
fatal: 60,
|
|
11
|
+
info: 30,
|
|
12
|
+
trace: 10,
|
|
13
|
+
warn: 40,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/* ── logger instance ── */
|
|
17
|
+
|
|
18
|
+
type LoggerInstance = {
|
|
19
|
+
child(bindings: Record<string, unknown>): LoggerInstance
|
|
20
|
+
debug(msg: string): void
|
|
21
|
+
debug(obj: Record<string, unknown>, msg?: string): void
|
|
22
|
+
error(msg: string): void
|
|
23
|
+
error(obj: Record<string, unknown>, msg?: string): void
|
|
24
|
+
fatal(msg: string): void
|
|
25
|
+
fatal(obj: Record<string, unknown>, msg?: string): void
|
|
26
|
+
info(msg: string): void
|
|
27
|
+
info(obj: Record<string, unknown>, msg?: string): void
|
|
28
|
+
level: LogLevel
|
|
29
|
+
trace(msg: string): void
|
|
30
|
+
trace(obj: Record<string, unknown>, msg?: string): void
|
|
31
|
+
warn(msg: string): void
|
|
32
|
+
warn(obj: Record<string, unknown>, msg?: string): void
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type CreateLoggerOptions = {
|
|
36
|
+
base?: Record<string, unknown>
|
|
37
|
+
level?: LogLevel
|
|
38
|
+
write?: (line: string) => void
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function createLogger(opts?: CreateLoggerOptions): LoggerInstance {
|
|
42
|
+
const threshold = LEVELS[opts?.level ?? "info"]
|
|
43
|
+
const write = opts?.write ?? ((line: string) => console.log(line))
|
|
44
|
+
const base = opts?.base ?? {}
|
|
45
|
+
|
|
46
|
+
function emit(level: number, first: Record<string, unknown> | string, second?: string): void {
|
|
47
|
+
if (level < threshold) return
|
|
48
|
+
const obj = typeof first === "string"
|
|
49
|
+
? { ...base, level, msg: first, time: Date.now() }
|
|
50
|
+
: { ...base, ...first, level, msg: second ?? "", time: Date.now() }
|
|
51
|
+
write(JSON.stringify(obj))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const instance: LoggerInstance = {
|
|
55
|
+
child(bindings: Record<string, unknown>): LoggerInstance {
|
|
56
|
+
return createLogger({
|
|
57
|
+
base: { ...base, ...bindings },
|
|
58
|
+
level: opts?.level,
|
|
59
|
+
write,
|
|
60
|
+
})
|
|
61
|
+
},
|
|
62
|
+
debug: (first: Record<string, unknown> | string, second?: string) => emit(LEVELS.debug, first, second),
|
|
63
|
+
error: (first: Record<string, unknown> | string, second?: string) => emit(LEVELS.error, first, second),
|
|
64
|
+
fatal: (first: Record<string, unknown> | string, second?: string) => emit(LEVELS.fatal, first, second),
|
|
65
|
+
info: (first: Record<string, unknown> | string, second?: string) => emit(LEVELS.info, first, second),
|
|
66
|
+
level: opts?.level ?? "info",
|
|
67
|
+
trace: (first: Record<string, unknown> | string, second?: string) => emit(LEVELS.trace, first, second),
|
|
68
|
+
warn: (first: Record<string, unknown> | string, second?: string) => emit(LEVELS.warn, first, second),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return instance
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/* ── request lifecycle middleware ── */
|
|
75
|
+
|
|
76
|
+
type LogData = {
|
|
77
|
+
duration: number
|
|
78
|
+
method: string
|
|
79
|
+
path: string
|
|
80
|
+
requestId: string | null
|
|
81
|
+
status: number
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
type LoggerOptions = {
|
|
85
|
+
instance?: LoggerInstance
|
|
86
|
+
log?: (data: LogData) => void
|
|
87
|
+
skip?: (data: LogData) => boolean
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function defaultLog(data: LogData): void {
|
|
91
|
+
const rid = data.requestId ? ` ${data.requestId}` : ""
|
|
92
|
+
console.log(`${data.method} ${data.path} ${data.status} ${data.duration.toFixed(2)}ms${rid}`)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const noop = () => {}
|
|
96
|
+
const noopLogger: LoggerInstance = createLogger({ level: "fatal", write: noop })
|
|
97
|
+
|
|
98
|
+
function logger(
|
|
99
|
+
options?: LoggerOptions,
|
|
100
|
+
): MiddlewareFn<{ path: string, req: Request }, { log: LoggerInstance }> {
|
|
101
|
+
const log = options?.log ?? defaultLog
|
|
102
|
+
const skip = options?.skip
|
|
103
|
+
const instance = options?.instance
|
|
104
|
+
|
|
105
|
+
return async (ctx, next) => {
|
|
106
|
+
const start = performance.now()
|
|
107
|
+
const method = ctx.req.method
|
|
108
|
+
const path = ctx.path
|
|
109
|
+
const rid = (ctx as Record<string, unknown>)["requestId"] as string | null ?? null
|
|
110
|
+
|
|
111
|
+
const child = instance
|
|
112
|
+
? instance.child({
|
|
113
|
+
method,
|
|
114
|
+
path,
|
|
115
|
+
...(rid ? { requestId: rid } : {}),
|
|
116
|
+
})
|
|
117
|
+
: noopLogger
|
|
118
|
+
|
|
119
|
+
if (instance) {
|
|
120
|
+
child.info(`--> ${method} ${path}`)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const response = await next({ log: child })
|
|
124
|
+
|
|
125
|
+
const duration = performance.now() - start
|
|
126
|
+
const data: LogData = {
|
|
127
|
+
duration,
|
|
128
|
+
method,
|
|
129
|
+
path,
|
|
130
|
+
requestId: rid,
|
|
131
|
+
status: response.status,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (skip?.(data)) {
|
|
135
|
+
return response
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (instance) {
|
|
139
|
+
child.info({ duration, status: response.status }, `<-- ${method} ${path}`)
|
|
140
|
+
} else {
|
|
141
|
+
log(data)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return response
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export { createLogger, logger }
|
|
149
|
+
export type { CreateLoggerOptions, LogData, LoggerInstance, LoggerOptions, LogLevel }
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/** phantom wrapper — at runtime it's just Response, carries TAdds for inference */
|
|
2
|
+
export type MiddlewareResult<TAdds = {}> = Response & {
|
|
3
|
+
readonly __adds?: TAdds;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export type MiddlewareFn<
|
|
7
|
+
TCtx = {},
|
|
8
|
+
TAdds = {},
|
|
9
|
+
TErrors extends string = string,
|
|
10
|
+
> = ((
|
|
11
|
+
ctx: TCtx,
|
|
12
|
+
next: {
|
|
13
|
+
<T>(additions: T): Promise<MiddlewareResult<T>>;
|
|
14
|
+
(): Promise<MiddlewareResult<{}>>;
|
|
15
|
+
},
|
|
16
|
+
) => Promise<MiddlewareResult<TAdds>>) & {
|
|
17
|
+
errors?: readonly TErrors[];
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** runtime type for stored middleware — erased generics, used internally */
|
|
21
|
+
export type RuntimeMiddleware = (
|
|
22
|
+
ctx: Record<string, unknown>,
|
|
23
|
+
next: (additions?: Record<string, unknown>) => Promise<Response>,
|
|
24
|
+
) => Promise<Response>;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Create middleware with error keys constrained to a factory.
|
|
28
|
+
* Error keys are stored on the function and auto-accumulated by .use().
|
|
29
|
+
*/
|
|
30
|
+
export function defineMiddleware<
|
|
31
|
+
TReqs,
|
|
32
|
+
TAdds,
|
|
33
|
+
TFactory extends Record<string, (...args: never[]) => unknown>,
|
|
34
|
+
TKeys extends keyof TFactory & string,
|
|
35
|
+
>(opts: {
|
|
36
|
+
errors?: [TFactory, ...TKeys[]];
|
|
37
|
+
fn: MiddlewareFn<TReqs, TAdds>;
|
|
38
|
+
}): MiddlewareFn<TReqs, TAdds, TKeys> {
|
|
39
|
+
const fn = opts.fn as MiddlewareFn<TReqs, TAdds, TKeys>;
|
|
40
|
+
if (opts.errors) {
|
|
41
|
+
const [, ...keys] = opts.errors;
|
|
42
|
+
Object.defineProperty(fn, "errors", { value: keys });
|
|
43
|
+
}
|
|
44
|
+
return fn;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type NextFn = {
|
|
48
|
+
<T>(additions: T): Promise<MiddlewareResult<T>>;
|
|
49
|
+
(): Promise<MiddlewareResult<{}>>;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** Extract TAdds from callback return type — distributes over unions */
|
|
53
|
+
type ExtractAdds<T> = T extends Promise<MiddlewareResult<infer A>> ? A : never
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Middleware factory — annotate ctx for TReqs, TAdds inferred from return type:
|
|
57
|
+
* createMiddleware((ctx: { env: { DB: D1Database } }, next) => next({ db }))
|
|
58
|
+
*/
|
|
59
|
+
export function createMiddleware<TReqs = {}, TRet extends Promise<Response> = Promise<MiddlewareResult<{}>>>(
|
|
60
|
+
fn: (ctx: TReqs, next: NextFn) => TRet,
|
|
61
|
+
): MiddlewareFn<TReqs, ExtractAdds<TRet>> {
|
|
62
|
+
return fn as MiddlewareFn<TReqs, ExtractAdds<TRet>>
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Context properties that middleware additions must not overwrite.
|
|
67
|
+
* These are set by the framework during request construction.
|
|
68
|
+
* TypeScript prevents this at compile time; this guard catches runtime bypasses.
|
|
69
|
+
*/
|
|
70
|
+
const RESERVED_CTX_KEYS = new Set([
|
|
71
|
+
"background",
|
|
72
|
+
"cookies",
|
|
73
|
+
"env",
|
|
74
|
+
"headers",
|
|
75
|
+
"params",
|
|
76
|
+
"req",
|
|
77
|
+
"res",
|
|
78
|
+
"search",
|
|
79
|
+
]);
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Compile a middleware chain + handler into a single function at registration time.
|
|
83
|
+
* Eliminates per-request recursive dispatch, closure allocation, and Promise.resolve wrapping.
|
|
84
|
+
*/
|
|
85
|
+
export function compileChain(
|
|
86
|
+
middlewares: RuntimeMiddleware[],
|
|
87
|
+
handler: (ctx: object) => Response | Promise<Response>,
|
|
88
|
+
): (ctx: object) => Response | Promise<Response> {
|
|
89
|
+
if (middlewares.length === 0) {
|
|
90
|
+
/* sync fast path — avoids microtask overhead for sync handlers */
|
|
91
|
+
return (ctx) => {
|
|
92
|
+
const result = handler(ctx);
|
|
93
|
+
if (result instanceof Promise) {
|
|
94
|
+
return result.then(validateResponse);
|
|
95
|
+
}
|
|
96
|
+
if (result === undefined || result === null) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
"handler must return a Response — did you forget 'return ctx.res...()'?",
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
return result;
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (middlewares.length === 1) {
|
|
106
|
+
const mw = middlewares[0];
|
|
107
|
+
if (!mw) {
|
|
108
|
+
throw new Error("middleware chain invariant violated");
|
|
109
|
+
}
|
|
110
|
+
return (ctx) => {
|
|
111
|
+
const mwCtx = ctx as Record<string, unknown>;
|
|
112
|
+
let called = false;
|
|
113
|
+
return Promise.resolve(
|
|
114
|
+
mw(mwCtx, (additions?: Record<string, unknown>) => {
|
|
115
|
+
if (called) throw new Error("next() called multiple times");
|
|
116
|
+
called = true;
|
|
117
|
+
if (additions) mergeAdditions(ctx, additions);
|
|
118
|
+
return Promise.resolve(handler(ctx)).then(validateResponse);
|
|
119
|
+
}),
|
|
120
|
+
).then(validateMiddlewareResponse);
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/* general case — pre-bind middleware array, no per-request index tracking */
|
|
125
|
+
return (ctx) => executeChain(middlewares, ctx, handler);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function mergeAdditions(ctx: object, additions: Record<string, unknown>): void {
|
|
129
|
+
for (const key in additions) {
|
|
130
|
+
if (!RESERVED_CTX_KEYS.has(key)) {
|
|
131
|
+
(ctx as Record<string, unknown>)[key] = additions[key];
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function validateResponse(result: Response): Response {
|
|
137
|
+
if (result === undefined || result === null) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
"handler must return a Response — did you forget 'return ctx.res...()'?",
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
return result;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function validateMiddlewareResponse(result: Response): Response {
|
|
146
|
+
if (result === undefined || result === null) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
"middleware must return a Response — did you forget 'return next(...)'?",
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
return result;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function executeChain(
|
|
155
|
+
middlewares: RuntimeMiddleware[],
|
|
156
|
+
ctx: object,
|
|
157
|
+
handler: (ctx: object) => Response | Promise<Response>,
|
|
158
|
+
): Promise<Response> {
|
|
159
|
+
let index = 0;
|
|
160
|
+
/* middleware runtime type uses Record<string, unknown> — object satisfies at runtime */
|
|
161
|
+
const mwCtx = ctx as Record<string, unknown>;
|
|
162
|
+
|
|
163
|
+
function dispatch(): Promise<Response> {
|
|
164
|
+
if (index >= middlewares.length) {
|
|
165
|
+
return Promise.resolve(handler(ctx)).then((result) => {
|
|
166
|
+
if (result === undefined || result === null) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
"handler must return a Response — did you forget 'return ctx.res...()'?",
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
return result;
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
const mw = middlewares[index++];
|
|
175
|
+
if (!mw) {
|
|
176
|
+
throw new Error("middleware chain invariant violated");
|
|
177
|
+
}
|
|
178
|
+
let called = false;
|
|
179
|
+
return Promise.resolve(
|
|
180
|
+
mw(mwCtx, (additions?: Record<string, unknown>) => {
|
|
181
|
+
if (called) {
|
|
182
|
+
throw new Error("next() called multiple times");
|
|
183
|
+
}
|
|
184
|
+
called = true;
|
|
185
|
+
if (additions) mergeAdditions(ctx, additions);
|
|
186
|
+
return dispatch();
|
|
187
|
+
}),
|
|
188
|
+
).then((result) => {
|
|
189
|
+
if (result === undefined || result === null) {
|
|
190
|
+
throw new Error(
|
|
191
|
+
"middleware must return a Response — did you forget 'return next(...)'?",
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
return result;
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return dispatch();
|
|
199
|
+
}
|