@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,265 @@
|
|
|
1
|
+
import type { ClientConfig, RequestMeta } from "./http.ts"
|
|
2
|
+
import { HTTPClient, interpolatePath } from "./http.ts"
|
|
3
|
+
import { createTypedWebSocket } from "./ws.ts"
|
|
4
|
+
|
|
5
|
+
type ServiceEntry = {
|
|
6
|
+
invalidate?: readonly string[]
|
|
7
|
+
method: string
|
|
8
|
+
params?: readonly string[]
|
|
9
|
+
path: string
|
|
10
|
+
sse?: boolean
|
|
11
|
+
ws?: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type ServiceMapNode = ServiceEntry | { [key: string]: ServiceMapNode }
|
|
15
|
+
type ServiceMap = { [key: string]: ServiceMapNode }
|
|
16
|
+
|
|
17
|
+
export type InvalidationConfig = {
|
|
18
|
+
staleTime: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type SDKConfig = ClientConfig & {
|
|
22
|
+
invalidation?: InvalidationConfig
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/* bridge generic — Proxy target is object, return type is T (erased at runtime) */
|
|
26
|
+
function bridge<T>(value: object): T {
|
|
27
|
+
return value as T
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/* convert OpenAPI {param} to :param for HTTPClient */
|
|
31
|
+
function toColonParams(path: string): string {
|
|
32
|
+
return path.replace(/\{(\w+)\}/g, ":$1")
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/* resolve invalidation targets by substituting mutation params */
|
|
36
|
+
export function resolveInvalidationTargets(
|
|
37
|
+
targets: readonly string[],
|
|
38
|
+
params: Record<string, string> | undefined,
|
|
39
|
+
): string[] {
|
|
40
|
+
const resolved: string[] = []
|
|
41
|
+
for (const target of targets) {
|
|
42
|
+
if (!params || !target.includes(":")) { resolved.push(target); continue }
|
|
43
|
+
const spaceIdx = target.indexOf(" ")
|
|
44
|
+
const targetMethod = target.slice(0, spaceIdx)
|
|
45
|
+
const targetPath = target.slice(spaceIdx + 1)
|
|
46
|
+
let hasUnresolved = false
|
|
47
|
+
const replaced = targetPath.replace(/:(\w+)/g, (match, key: string) => {
|
|
48
|
+
const val = params[key]
|
|
49
|
+
if (val === undefined) { hasUnresolved = true; return match }
|
|
50
|
+
return encodeURIComponent(val)
|
|
51
|
+
})
|
|
52
|
+
if (hasUnresolved) continue
|
|
53
|
+
resolved.push(`${targetMethod} ${replaced}`)
|
|
54
|
+
}
|
|
55
|
+
return resolved
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/* cache compiled pattern regexes — hot path for invalidation lookups */
|
|
59
|
+
const patternRegexCache = new Map<string, RegExp>()
|
|
60
|
+
|
|
61
|
+
/* test if a concrete path matches a route pattern; escapes regex metacharacters in the pattern */
|
|
62
|
+
export function pathMatchesPattern(concretePath: string, pattern: string): boolean {
|
|
63
|
+
let re = patternRegexCache.get(pattern)
|
|
64
|
+
if (!re) {
|
|
65
|
+
/* split on :param placeholders, escape each literal segment's metachars, rejoin with [^/]+ */
|
|
66
|
+
const parts = pattern.split(/:[^/]+/g)
|
|
67
|
+
const escaped = parts
|
|
68
|
+
.map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
|
69
|
+
.join("[^/]+")
|
|
70
|
+
re = new RegExp(`^${escaped}$`)
|
|
71
|
+
patternRegexCache.set(pattern, re)
|
|
72
|
+
}
|
|
73
|
+
return re.test(concretePath)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/* two-tier stale lookup: exact concrete match, then pattern fallback; sweeps expired entries in one pass */
|
|
77
|
+
export function lookupStale(
|
|
78
|
+
staleMap: Map<string, { by: string[], seq: number, until: number }>,
|
|
79
|
+
concreteSelector: string,
|
|
80
|
+
concretePath: string,
|
|
81
|
+
method: string,
|
|
82
|
+
now: number,
|
|
83
|
+
): { by: string[], isStale: boolean } {
|
|
84
|
+
const allBy: string[] = []
|
|
85
|
+
const expired: string[] = []
|
|
86
|
+
|
|
87
|
+
const exact = staleMap.get(concreteSelector)
|
|
88
|
+
if (exact && exact.until > now) allBy.push(...exact.by)
|
|
89
|
+
else if (exact) expired.push(concreteSelector)
|
|
90
|
+
|
|
91
|
+
for (const [key, entry] of staleMap) {
|
|
92
|
+
if (entry.until <= now) { expired.push(key); continue }
|
|
93
|
+
if (!key.includes(":")) continue
|
|
94
|
+
const spaceIdx = key.indexOf(" ")
|
|
95
|
+
const keyMethod = key.slice(0, spaceIdx)
|
|
96
|
+
const keyPattern = key.slice(spaceIdx + 1)
|
|
97
|
+
if (keyMethod !== method) continue
|
|
98
|
+
if (pathMatchesPattern(concretePath, keyPattern)) {
|
|
99
|
+
allBy.push(...entry.by)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
for (const key of expired) staleMap.delete(key)
|
|
103
|
+
|
|
104
|
+
return { by: [...new Set(allBy)], isStale: allBy.length > 0 }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function createSDK<T = Record<string, Record<string, (...args: unknown[]) => unknown>>>(
|
|
108
|
+
serviceMap: ServiceMap,
|
|
109
|
+
config: SDKConfig,
|
|
110
|
+
): T {
|
|
111
|
+
const http = new HTTPClient(config)
|
|
112
|
+
const staleTime = config.invalidation?.staleTime ?? 0
|
|
113
|
+
const hasInvalidation = staleTime > 0
|
|
114
|
+
const staleUntil = hasInvalidation
|
|
115
|
+
? new Map<string, { by: string[], seq: number, until: number }>()
|
|
116
|
+
: null
|
|
117
|
+
|
|
118
|
+
let invalidationSeq = 0
|
|
119
|
+
|
|
120
|
+
const rootCache = new Map<string, object>()
|
|
121
|
+
|
|
122
|
+
function buildLeafFn(entry: ServiceEntry, namePath: string[]): Function {
|
|
123
|
+
const path = toColonParams(entry.path)
|
|
124
|
+
const method = entry.method
|
|
125
|
+
let fn: Function
|
|
126
|
+
|
|
127
|
+
if (entry.ws) {
|
|
128
|
+
fn = (input?: Record<string, unknown>) => {
|
|
129
|
+
const opts = {
|
|
130
|
+
params: input?.["params"] as Record<string, string> | undefined,
|
|
131
|
+
search: input?.["search"] as Record<string, unknown> | undefined,
|
|
132
|
+
}
|
|
133
|
+
let url = http.buildWSUrl(path, opts)
|
|
134
|
+
const reconnectToken = input?.["reconnectToken"] as string | undefined
|
|
135
|
+
if (reconnectToken) {
|
|
136
|
+
const sep = url.includes("?") ? "&" : "?"
|
|
137
|
+
url = `${url}${sep}reconnect_token=${encodeURIComponent(reconnectToken)}`
|
|
138
|
+
}
|
|
139
|
+
return createTypedWebSocket(url, {
|
|
140
|
+
protocols: input?.["protocols"] as string | string[] | undefined,
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
} else if (entry.sse) {
|
|
144
|
+
fn = (input?: Record<string, unknown>) => http.requestStream(method, path, input ?? {})
|
|
145
|
+
} else {
|
|
146
|
+
fn = async (input?: Record<string, unknown>) => {
|
|
147
|
+
const opts = input ?? {}
|
|
148
|
+
const params = opts["params"] as Record<string, string> | undefined
|
|
149
|
+
const concretePath = params ? interpolatePath(path, params) : path
|
|
150
|
+
const concreteSelector = `${method} ${concretePath}`
|
|
151
|
+
let requestMeta: RequestMeta | undefined
|
|
152
|
+
|
|
153
|
+
if (staleUntil) {
|
|
154
|
+
const now = Date.now()
|
|
155
|
+
const { by, isStale } = lookupStale(staleUntil, concreteSelector, concretePath, method, now)
|
|
156
|
+
requestMeta = { invalidatedBy: by, isStale, selector: concreteSelector, seqSnapshot: invalidationSeq }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const result = config.throwOnError === true
|
|
160
|
+
? await http.request(method, path, opts, requestMeta)
|
|
161
|
+
: await http.requestSafe(method, path, opts, requestMeta)
|
|
162
|
+
|
|
163
|
+
if (staleUntil) {
|
|
164
|
+
const isSuccess = config.throwOnError === true
|
|
165
|
+
? true
|
|
166
|
+
: ((result as { status: number }).status >= 200 && (result as { status: number }).status < 300)
|
|
167
|
+
|
|
168
|
+
if (isSuccess) {
|
|
169
|
+
if (entry.invalidate && entry.invalidate.length > 0) {
|
|
170
|
+
const seq = ++invalidationSeq
|
|
171
|
+
const until = Date.now() + staleTime
|
|
172
|
+
const mutationSelector = concreteSelector
|
|
173
|
+
const resolved = resolveInvalidationTargets(entry.invalidate, params)
|
|
174
|
+
for (const target of resolved) {
|
|
175
|
+
const existing = staleUntil.get(target)
|
|
176
|
+
if (existing) {
|
|
177
|
+
if (!existing.by.includes(mutationSelector)) existing.by.push(mutationSelector)
|
|
178
|
+
existing.until = until
|
|
179
|
+
existing.seq = seq
|
|
180
|
+
} else {
|
|
181
|
+
staleUntil.set(target, { by: [mutationSelector], seq, until })
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (requestMeta?.isStale) {
|
|
187
|
+
const snapshot = requestMeta.seqSnapshot
|
|
188
|
+
const exact = staleUntil.get(concreteSelector)
|
|
189
|
+
if (exact && exact.seq <= snapshot) staleUntil.delete(concreteSelector)
|
|
190
|
+
const toDelete: string[] = []
|
|
191
|
+
for (const [key, staleEntry] of staleUntil) {
|
|
192
|
+
if (staleEntry.seq > snapshot) continue
|
|
193
|
+
if (!key.includes(":")) continue
|
|
194
|
+
const spaceIdx = key.indexOf(" ")
|
|
195
|
+
const keyMethod = key.slice(0, spaceIdx)
|
|
196
|
+
const keyPattern = key.slice(spaceIdx + 1)
|
|
197
|
+
if (keyMethod === method && pathMatchesPattern(concretePath, keyPattern)) toDelete.push(key)
|
|
198
|
+
}
|
|
199
|
+
for (const key of toDelete) staleUntil.delete(key)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return result
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
Object.defineProperty(fn, "name", { value: namePath.join(".") })
|
|
209
|
+
return fn
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function makeNodeProxy(node: ServiceMapNode, namePath: string[]): object {
|
|
213
|
+
const actionCache = new Map<string, Function>()
|
|
214
|
+
const childCache = new Map<string, object>()
|
|
215
|
+
return new Proxy({} as Record<string, unknown>, {
|
|
216
|
+
get(_, key: string | symbol) {
|
|
217
|
+
if (typeof key === "symbol") return undefined
|
|
218
|
+
const child = (node as Record<string, unknown>)[key]
|
|
219
|
+
if (child === undefined) return undefined
|
|
220
|
+
|
|
221
|
+
if (typeof (child as Record<string, unknown>)["method"] === "string") {
|
|
222
|
+
const cached = actionCache.get(key)
|
|
223
|
+
if (cached) return cached
|
|
224
|
+
const fn = buildLeafFn(child as ServiceEntry, [...namePath, key])
|
|
225
|
+
actionCache.set(key, fn)
|
|
226
|
+
return fn
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const cached = childCache.get(key)
|
|
230
|
+
if (cached) return cached
|
|
231
|
+
const childProxy = makeNodeProxy(child as ServiceMapNode, [...namePath, key])
|
|
232
|
+
childCache.set(key, childProxy)
|
|
233
|
+
return childProxy
|
|
234
|
+
},
|
|
235
|
+
})
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return bridge<T>(
|
|
239
|
+
new Proxy(
|
|
240
|
+
{},
|
|
241
|
+
{
|
|
242
|
+
get(_target, key: string | symbol) {
|
|
243
|
+
if (typeof key === "symbol") return undefined
|
|
244
|
+
|
|
245
|
+
const cached = rootCache.get(key as string)
|
|
246
|
+
if (cached) return cached
|
|
247
|
+
|
|
248
|
+
const node = serviceMap[key as string]
|
|
249
|
+
if (node === undefined) return undefined
|
|
250
|
+
|
|
251
|
+
let result: object
|
|
252
|
+
if (typeof (node as Record<string, unknown>)["method"] === "string") {
|
|
253
|
+
const fn = buildLeafFn(node as ServiceEntry, [key as string])
|
|
254
|
+
result = fn as unknown as object
|
|
255
|
+
} else {
|
|
256
|
+
result = makeNodeProxy(node, [key as string])
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
rootCache.set(key as string, result)
|
|
260
|
+
return result
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
),
|
|
264
|
+
)
|
|
265
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
export type SSEEvent = {
|
|
2
|
+
data: string
|
|
3
|
+
event?: string
|
|
4
|
+
id?: string
|
|
5
|
+
retry?: number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function parseBlock(block: string): SSEEvent | undefined {
|
|
9
|
+
const lines = block.split(/\r\n|\r|\n/)
|
|
10
|
+
let isComment = true
|
|
11
|
+
let data: string | undefined
|
|
12
|
+
let event: string | undefined
|
|
13
|
+
let id: string | undefined
|
|
14
|
+
let retry: number | undefined
|
|
15
|
+
|
|
16
|
+
for (const line of lines) {
|
|
17
|
+
if (line.startsWith(":")) continue
|
|
18
|
+
isComment = false
|
|
19
|
+
|
|
20
|
+
const colonIdx = line.indexOf(":")
|
|
21
|
+
let field: string
|
|
22
|
+
let val: string
|
|
23
|
+
if (colonIdx === -1) {
|
|
24
|
+
field = line
|
|
25
|
+
val = ""
|
|
26
|
+
} else {
|
|
27
|
+
field = line.slice(0, colonIdx)
|
|
28
|
+
val = line.slice(colonIdx + 1)
|
|
29
|
+
if (val.startsWith(" ")) val = val.slice(1)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
switch (field) {
|
|
33
|
+
case "data":
|
|
34
|
+
data = data === undefined ? val : `${data}\n${val}`
|
|
35
|
+
break
|
|
36
|
+
case "event":
|
|
37
|
+
event = val
|
|
38
|
+
break
|
|
39
|
+
case "id":
|
|
40
|
+
/* per spec: id containing null byte must be ignored */
|
|
41
|
+
if (!val.includes("\0")) id = val
|
|
42
|
+
break
|
|
43
|
+
case "retry": {
|
|
44
|
+
const n = Number(val)
|
|
45
|
+
if (Number.isFinite(n) && n >= 0) retry = n
|
|
46
|
+
break
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (isComment || data === undefined) return undefined
|
|
52
|
+
|
|
53
|
+
const sse: SSEEvent = { data }
|
|
54
|
+
if (event !== undefined) sse.event = event
|
|
55
|
+
if (id !== undefined) sse.id = id
|
|
56
|
+
if (retry !== undefined) sse.retry = retry
|
|
57
|
+
return sse
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const DEFAULT_MAX_BUFFER = 1024 * 1024
|
|
61
|
+
|
|
62
|
+
export async function* parseSSEStream(
|
|
63
|
+
stream: ReadableStream<Uint8Array>,
|
|
64
|
+
opts?: { maxBufferSize?: number },
|
|
65
|
+
): AsyncGenerator<SSEEvent, void, undefined> {
|
|
66
|
+
const decoder = new TextDecoder()
|
|
67
|
+
const reader = stream.getReader()
|
|
68
|
+
let buffer = ""
|
|
69
|
+
const maxBuffer = opts?.maxBufferSize ?? DEFAULT_MAX_BUFFER
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
while (true) {
|
|
73
|
+
const { done, value } = await reader.read()
|
|
74
|
+
if (done) break
|
|
75
|
+
buffer += decoder.decode(value, { stream: true })
|
|
76
|
+
|
|
77
|
+
if (buffer.length > maxBuffer) {
|
|
78
|
+
throw new Error(`SSE buffer exceeded ${maxBuffer} characters`)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* split on double-newline (any combo of \r\n, \r, \n) */
|
|
82
|
+
const blocks = buffer.split(/\r\n\r\n|\r\n\r|\r\n\n|\r\r\n|\n\r\n|\n\r|\r\r|\n\n/)
|
|
83
|
+
/* last element is incomplete — keep in buffer */
|
|
84
|
+
buffer = blocks.pop() ?? ""
|
|
85
|
+
|
|
86
|
+
for (const block of blocks) {
|
|
87
|
+
if (block.trim() === "") continue
|
|
88
|
+
const event = parseBlock(block)
|
|
89
|
+
if (event) yield event
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/* flush remaining buffer */
|
|
94
|
+
if (buffer.trim() !== "") {
|
|
95
|
+
const event = parseBlock(buffer)
|
|
96
|
+
if (event) yield event
|
|
97
|
+
}
|
|
98
|
+
} finally {
|
|
99
|
+
await reader.cancel()
|
|
100
|
+
reader.releaseLock()
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import type { FieldError, InferOutput, ParamsFromPath, StandardSchemaLike } from "../types.ts"
|
|
2
|
+
import type { SSEEvent } from "./sse.ts"
|
|
3
|
+
import type { TypedWebSocket } from "./ws.ts"
|
|
4
|
+
|
|
5
|
+
/** Extract paths that have a specific method registered */
|
|
6
|
+
export type PathsForMethod<TRoutes, M extends string> = {
|
|
7
|
+
[P in keyof TRoutes & string]: M extends keyof TRoutes[P] ? P : never
|
|
8
|
+
}[keyof TRoutes & string]
|
|
9
|
+
|
|
10
|
+
/** Extract input type for a route */
|
|
11
|
+
export type InputFor<TRoutes, P extends string, M extends string> = P extends keyof TRoutes
|
|
12
|
+
? M extends keyof TRoutes[P]
|
|
13
|
+
? TRoutes[P][M] extends { input: infer I }
|
|
14
|
+
? I
|
|
15
|
+
: {}
|
|
16
|
+
: {}
|
|
17
|
+
: {}
|
|
18
|
+
|
|
19
|
+
/** Extract output schema definition for a route */
|
|
20
|
+
export type OutputDefFor<TRoutes, P extends string, M extends string> = P extends keyof TRoutes
|
|
21
|
+
? M extends keyof TRoutes[P]
|
|
22
|
+
? TRoutes[P][M] extends { output: infer O }
|
|
23
|
+
? O
|
|
24
|
+
: never
|
|
25
|
+
: never
|
|
26
|
+
: never
|
|
27
|
+
|
|
28
|
+
/** Check if output definition is empty (no .output() declared) */
|
|
29
|
+
type IsEmptyOutput<O> = keyof O extends never ? true : false
|
|
30
|
+
|
|
31
|
+
/** Content-type to return type mapping for non-JSON outputs */
|
|
32
|
+
type ContentTypeReturn<O> = O extends { "text/plain": unknown }
|
|
33
|
+
? string
|
|
34
|
+
: O extends { "text/html": unknown }
|
|
35
|
+
? string
|
|
36
|
+
: O extends { "text/csv": unknown }
|
|
37
|
+
? string
|
|
38
|
+
: O extends { "application/xml": unknown }
|
|
39
|
+
? string
|
|
40
|
+
: O extends { "application/octet-stream": unknown }
|
|
41
|
+
? ArrayBuffer
|
|
42
|
+
: null
|
|
43
|
+
|
|
44
|
+
/** Extract the resolved output type — infer from schema validators in the output definition */
|
|
45
|
+
export type OutputFor<TRoutes, P extends string, M extends string> =
|
|
46
|
+
OutputDefFor<TRoutes, P, M> extends infer O
|
|
47
|
+
? [O] extends [never]
|
|
48
|
+
? null
|
|
49
|
+
: IsEmptyOutput<O> extends true
|
|
50
|
+
? null
|
|
51
|
+
: O extends { "application/json": infer JsonMap }
|
|
52
|
+
? JsonMap extends Record<string, StandardSchemaLike>
|
|
53
|
+
? InferOutput<JsonMap[keyof JsonMap]>
|
|
54
|
+
: null
|
|
55
|
+
: ContentTypeReturn<O>
|
|
56
|
+
: null
|
|
57
|
+
|
|
58
|
+
/** Check if a route returns SSE (text/event-stream in output) */
|
|
59
|
+
export type IsSSE<TRoutes, P extends string, M extends string> =
|
|
60
|
+
OutputDefFor<TRoutes, P, M> extends infer O
|
|
61
|
+
? O extends { "text/event-stream": unknown }
|
|
62
|
+
? true
|
|
63
|
+
: false
|
|
64
|
+
: false
|
|
65
|
+
|
|
66
|
+
/** Extract errorsByStatus from route record if available */
|
|
67
|
+
type ErrorsByStatusFor<TRoutes, P extends string, M extends string> = P extends keyof TRoutes
|
|
68
|
+
? M extends keyof TRoutes[P]
|
|
69
|
+
? TRoutes[P][M] extends { errorsByStatus: infer S }
|
|
70
|
+
? S
|
|
71
|
+
: never
|
|
72
|
+
: never
|
|
73
|
+
: never
|
|
74
|
+
|
|
75
|
+
/** Default Honey error envelope — used when errorsByStatus lists `null` (standard errors) */
|
|
76
|
+
export type ErrorEnvelope<TStatus extends number = number, TKey extends string = string> = {
|
|
77
|
+
error_key: TKey
|
|
78
|
+
fields: Record<string, FieldError[]>
|
|
79
|
+
message: string
|
|
80
|
+
status: TStatus
|
|
81
|
+
status_key: string
|
|
82
|
+
success: false
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** `null` in a route's errorsByStatus means the default envelope, not a null body */
|
|
86
|
+
type ResolveErrorBody<S extends number, T> = T extends null ? ErrorEnvelope<S> : T
|
|
87
|
+
|
|
88
|
+
/** Object keys of `{ 400: ... }` are `400` or `"400"` depending on TS version */
|
|
89
|
+
type StatusCode<K> = K extends number ? K : K extends `${infer N extends number}` ? N : never
|
|
90
|
+
|
|
91
|
+
type HasStatusErrors<T> = [StatusCode<keyof T>] extends [never] ? false : true
|
|
92
|
+
|
|
93
|
+
type ErrorVariants<TErrorsByStatus> = {
|
|
94
|
+
[K in keyof TErrorsByStatus]: StatusCode<K> extends never
|
|
95
|
+
? never
|
|
96
|
+
: {
|
|
97
|
+
data: null
|
|
98
|
+
error: ResolveErrorBody<StatusCode<K>, TErrorsByStatus[K]>
|
|
99
|
+
response: Response
|
|
100
|
+
status: StatusCode<K>
|
|
101
|
+
}
|
|
102
|
+
}[keyof TErrorsByStatus]
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Result tuple for safe (non-throwing) mode — error is the raw parsed body, not ClientError.
|
|
106
|
+
* When TErrorsByStatus is known, the typed branch covers every declared error status
|
|
107
|
+
* (no unknown fallback) so `if (error)` / `if (!error)` narrow.
|
|
108
|
+
*/
|
|
109
|
+
export type ClientResult<TData, TErrorsByStatus = never> =
|
|
110
|
+
| { data: TData; error: null; response: Response; status: number }
|
|
111
|
+
| (HasStatusErrors<TErrorsByStatus> extends false
|
|
112
|
+
? { data: null; error: unknown; response: Response; status: number }
|
|
113
|
+
: ErrorVariants<TErrorsByStatus>)
|
|
114
|
+
|
|
115
|
+
/** Resolve return type based on transport + throw mode */
|
|
116
|
+
export type ReturnFor<TRoutes, P extends string, M extends string, TThrow extends boolean = true> =
|
|
117
|
+
IsSSE<TRoutes, P, M> extends true
|
|
118
|
+
? AsyncIterable<SSEEvent>
|
|
119
|
+
: TThrow extends true
|
|
120
|
+
? Promise<OutputFor<TRoutes, P, M>>
|
|
121
|
+
: Promise<ClientResult<OutputFor<TRoutes, P, M>, ErrorsByStatusFor<TRoutes, P, M>>>
|
|
122
|
+
|
|
123
|
+
/** Build the client input type — merge route input with params from path */
|
|
124
|
+
export type ClientInput<TRoutes, P extends string, M extends string> = InputFor<TRoutes, P, M> &
|
|
125
|
+
ParamsInput<P>
|
|
126
|
+
|
|
127
|
+
/** Extract params input if path has params */
|
|
128
|
+
type ParamsInput<P extends string> =
|
|
129
|
+
ParamsFromPath<P> extends Record<string, string>
|
|
130
|
+
? HasParams<P> extends true
|
|
131
|
+
? { params: ParamsFromPath<P> }
|
|
132
|
+
: {}
|
|
133
|
+
: {}
|
|
134
|
+
|
|
135
|
+
/** Check if path has any param segments */
|
|
136
|
+
type HasParams<P extends string> = P extends `${string}:${string}` ? true : false
|
|
137
|
+
|
|
138
|
+
/** Extract error keys for a route */
|
|
139
|
+
export type ErrorsFor<TRoutes, P extends string, M extends string> = P extends keyof TRoutes
|
|
140
|
+
? M extends keyof TRoutes[P]
|
|
141
|
+
? TRoutes[P][M] extends { errors: infer E extends string }
|
|
142
|
+
? E
|
|
143
|
+
: never
|
|
144
|
+
: never
|
|
145
|
+
: never
|
|
146
|
+
|
|
147
|
+
/** The typed client interface — TThrow controls return type */
|
|
148
|
+
export type HoneyClient<TRoutes, TThrow extends boolean = false> = {
|
|
149
|
+
$isClientError: (e: unknown) => e is import("./error.ts").ClientError
|
|
150
|
+
|
|
151
|
+
$path<P extends keyof TRoutes & string>(
|
|
152
|
+
path: P,
|
|
153
|
+
input?: { params?: Record<string, string>; search?: Record<string, unknown> },
|
|
154
|
+
): string
|
|
155
|
+
|
|
156
|
+
$url<P extends keyof TRoutes & string>(
|
|
157
|
+
path: P,
|
|
158
|
+
input?: { params?: Record<string, string>; search?: Record<string, unknown> },
|
|
159
|
+
): string
|
|
160
|
+
|
|
161
|
+
delete<P extends PathsForMethod<TRoutes, "delete">>(
|
|
162
|
+
path: P,
|
|
163
|
+
...input: OptionalInput<ClientInput<TRoutes, P, "delete">>
|
|
164
|
+
): ReturnFor<TRoutes, P, "delete", TThrow>
|
|
165
|
+
|
|
166
|
+
get<P extends PathsForMethod<TRoutes, "get">>(
|
|
167
|
+
path: P,
|
|
168
|
+
...input: OptionalInput<ClientInput<TRoutes, P, "get">>
|
|
169
|
+
): ReturnFor<TRoutes, P, "get", TThrow>
|
|
170
|
+
|
|
171
|
+
patch<P extends PathsForMethod<TRoutes, "patch">>(
|
|
172
|
+
path: P,
|
|
173
|
+
...input: OptionalInput<ClientInput<TRoutes, P, "patch">>
|
|
174
|
+
): ReturnFor<TRoutes, P, "patch", TThrow>
|
|
175
|
+
|
|
176
|
+
post<P extends PathsForMethod<TRoutes, "post">>(
|
|
177
|
+
path: P,
|
|
178
|
+
...input: OptionalInput<ClientInput<TRoutes, P, "post">>
|
|
179
|
+
): ReturnFor<TRoutes, P, "post", TThrow>
|
|
180
|
+
|
|
181
|
+
put<P extends PathsForMethod<TRoutes, "put">>(
|
|
182
|
+
path: P,
|
|
183
|
+
...input: OptionalInput<ClientInput<TRoutes, P, "put">>
|
|
184
|
+
): ReturnFor<TRoutes, P, "put", TThrow>
|
|
185
|
+
|
|
186
|
+
ws(
|
|
187
|
+
path: string,
|
|
188
|
+
input?: {
|
|
189
|
+
params?: Record<string, string>
|
|
190
|
+
protocols?: string | string[]
|
|
191
|
+
reconnectToken?: string
|
|
192
|
+
search?: Record<string, unknown>
|
|
193
|
+
},
|
|
194
|
+
): TypedWebSocket
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Make input optional when it's an empty object */
|
|
198
|
+
type OptionalInput<T> = keyof T extends never
|
|
199
|
+
? [input?: T]
|
|
200
|
+
: [RequiresAllOptional<T>] extends [true]
|
|
201
|
+
? [input?: T]
|
|
202
|
+
: [input: T]
|
|
203
|
+
|
|
204
|
+
/** Check if all keys in T are optional */
|
|
205
|
+
type RequiresAllOptional<T> = {
|
|
206
|
+
[K in keyof T]-?: undefined extends T[K] ? true : false
|
|
207
|
+
}[keyof T] extends true
|
|
208
|
+
? true
|
|
209
|
+
: Partial<T> extends T
|
|
210
|
+
? true
|
|
211
|
+
: false
|
package/src/client/ws.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
export type TypedWebSocket = {
|
|
2
|
+
close(code?: number, reason?: string): void
|
|
3
|
+
off(event: "close" | "error" | "message" | "open", handler: (...args: never[]) => void): void
|
|
4
|
+
on(event: "close", handler: (code: number, reason: string) => void): void
|
|
5
|
+
on(event: "error", handler: (error: unknown) => void): void
|
|
6
|
+
on(event: "message", handler: (data: string) => void): void
|
|
7
|
+
on(event: "open", handler: () => void): void
|
|
8
|
+
readonly readyState: number
|
|
9
|
+
send(data: ArrayBuffer | ArrayBufferView | object | string): void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type WSOptions = {
|
|
13
|
+
protocols?: string | string[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createTypedWebSocket(
|
|
17
|
+
url: string,
|
|
18
|
+
opts?: WSOptions,
|
|
19
|
+
WebSocketImpl: typeof WebSocket = WebSocket,
|
|
20
|
+
): TypedWebSocket {
|
|
21
|
+
const ws = opts?.protocols ? new WebSocketImpl(url, opts.protocols) : new WebSocketImpl(url)
|
|
22
|
+
|
|
23
|
+
/* map user handlers → wrapped listeners for proper removal */
|
|
24
|
+
const listenerMap = new WeakMap<(...args: never[]) => void, EventListener>()
|
|
25
|
+
/* buffer sends until OPEN */
|
|
26
|
+
const sendBuffer: Array<string | Uint8Array<ArrayBuffer>> = []
|
|
27
|
+
let buffering = true
|
|
28
|
+
|
|
29
|
+
ws.addEventListener("open", () => {
|
|
30
|
+
buffering = false
|
|
31
|
+
for (const msg of sendBuffer) {
|
|
32
|
+
ws.send(msg)
|
|
33
|
+
}
|
|
34
|
+
sendBuffer.length = 0
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
function close(code?: number, reason?: string) {
|
|
38
|
+
buffering = false
|
|
39
|
+
sendBuffer.length = 0
|
|
40
|
+
ws.close(code, reason)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function on(event: "close", handler: (code: number, reason: string) => void): void
|
|
44
|
+
function on(event: "error", handler: (error: unknown) => void): void
|
|
45
|
+
function on(event: "message", handler: (data: string) => void): void
|
|
46
|
+
function on(event: "open", handler: () => void): void
|
|
47
|
+
function on(event: string, handler: (...args: never[]) => void): void {
|
|
48
|
+
let wrapped: EventListener
|
|
49
|
+
switch (event) {
|
|
50
|
+
case "message":
|
|
51
|
+
wrapped = (e: Event) => (handler as (data: string) => void)((e as MessageEvent).data)
|
|
52
|
+
break
|
|
53
|
+
case "open":
|
|
54
|
+
wrapped = () => (handler as () => void)()
|
|
55
|
+
break
|
|
56
|
+
case "close":
|
|
57
|
+
wrapped = (e: Event) =>
|
|
58
|
+
(handler as (code: number, reason: string) => void)(
|
|
59
|
+
(e as CloseEvent).code,
|
|
60
|
+
(e as CloseEvent).reason,
|
|
61
|
+
)
|
|
62
|
+
break
|
|
63
|
+
case "error":
|
|
64
|
+
wrapped = (e: Event) => (handler as (error: unknown) => void)(e)
|
|
65
|
+
break
|
|
66
|
+
default:
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
listenerMap.set(handler, wrapped)
|
|
70
|
+
ws.addEventListener(event, wrapped)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function off(event: string, handler: (...args: never[]) => void): void {
|
|
74
|
+
const wrapped = listenerMap.get(handler)
|
|
75
|
+
if (wrapped) {
|
|
76
|
+
ws.removeEventListener(event, wrapped)
|
|
77
|
+
listenerMap.delete(handler)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function toWsData(data: ArrayBuffer | ArrayBufferView | string): string | Uint8Array<ArrayBuffer> {
|
|
82
|
+
if (typeof data === "string") return data
|
|
83
|
+
const view = data instanceof ArrayBuffer ? new Uint8Array(data) : new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
|
|
84
|
+
const copy = new Uint8Array(view.byteLength)
|
|
85
|
+
copy.set(view)
|
|
86
|
+
return copy
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function send(data: ArrayBuffer | ArrayBufferView | object | string) {
|
|
90
|
+
const payload =
|
|
91
|
+
typeof data === "string" || data instanceof ArrayBuffer || ArrayBuffer.isView(data)
|
|
92
|
+
? toWsData(data)
|
|
93
|
+
: JSON.stringify(data)
|
|
94
|
+
|
|
95
|
+
if (buffering) {
|
|
96
|
+
sendBuffer.push(payload)
|
|
97
|
+
} else {
|
|
98
|
+
ws.send(payload)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const typed: TypedWebSocket = {
|
|
103
|
+
close,
|
|
104
|
+
off,
|
|
105
|
+
on,
|
|
106
|
+
get readyState() {
|
|
107
|
+
return ws.readyState
|
|
108
|
+
},
|
|
109
|
+
send,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
Object.defineProperty(typed, "_ws", { enumerable: false, value: ws })
|
|
113
|
+
|
|
114
|
+
return typed
|
|
115
|
+
}
|