@lovrozagar/honey 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/README.md +51 -0
  2. package/docs/api-ref/go.md +399 -0
  3. package/docs/api-ref/python.md +399 -0
  4. package/docs/api-ref/rust.md +399 -0
  5. package/docs/api-ref/typescript.md +399 -0
  6. package/docs/invalidate-spec.md +303 -0
  7. package/docs/sdk.md +722 -0
  8. package/package.json +120 -0
  9. package/src/accepts.ts +78 -0
  10. package/src/body-limit.ts +100 -0
  11. package/src/build/index.ts +174 -0
  12. package/src/cli-go/auth.go +15 -0
  13. package/src/cli-go/config.go +165 -0
  14. package/src/cli-go/errors.go +57 -0
  15. package/src/cli-go/multipart.go +55 -0
  16. package/src/cli-go/output.go +109 -0
  17. package/src/cli-go/root.go +30 -0
  18. package/src/cli-go/stream.go +88 -0
  19. package/src/cli-go/version.go +5 -0
  20. package/src/cli.ts +204 -0
  21. package/src/client/error.ts +27 -0
  22. package/src/client/http.ts +502 -0
  23. package/src/client/index.ts +127 -0
  24. package/src/client/sdk.ts +265 -0
  25. package/src/client/sse.ts +102 -0
  26. package/src/client/types.ts +211 -0
  27. package/src/client/ws.ts +115 -0
  28. package/src/client-go/errors.go +207 -0
  29. package/src/client-go/invalidation.go +373 -0
  30. package/src/client-go/realtime.go +317 -0
  31. package/src/client-go/result.go +20 -0
  32. package/src/client-go/runtime.go +413 -0
  33. package/src/client-go/sse.go +149 -0
  34. package/src/client-go/transport.go +452 -0
  35. package/src/client-go/ws.go +104 -0
  36. package/src/client-mcp/README.md +10 -0
  37. package/src/client-mcp/server.ts +94 -0
  38. package/src/client-mcp/types.ts +19 -0
  39. package/src/client-python/_errors.py +115 -0
  40. package/src/client-python/_invalidation.py +578 -0
  41. package/src/client-python/_realtime.py +156 -0
  42. package/src/client-python/_runtime.py +469 -0
  43. package/src/client-python/_sse.py +99 -0
  44. package/src/client-python/_transport.py +335 -0
  45. package/src/client-python/_ws.py +131 -0
  46. package/src/client-python/py.typed +0 -0
  47. package/src/client-rust/errors.rs +200 -0
  48. package/src/client-rust/invalidation.rs +441 -0
  49. package/src/client-rust/invalidation_sync.rs +306 -0
  50. package/src/client-rust/realtime.rs +695 -0
  51. package/src/client-rust/result.rs +22 -0
  52. package/src/client-rust/runtime.rs +547 -0
  53. package/src/client-rust/runtime_sync.rs +452 -0
  54. package/src/client-rust/sse.rs +167 -0
  55. package/src/client-rust/ws.rs +143 -0
  56. package/src/cloudflare/realtime.ts +236 -0
  57. package/src/codegen-go-cli.ts +1180 -0
  58. package/src/codegen-go.ts +1010 -0
  59. package/src/codegen-hash.ts +8 -0
  60. package/src/codegen-ir.ts +517 -0
  61. package/src/codegen-loaders.ts +53 -0
  62. package/src/codegen-mcp.ts +364 -0
  63. package/src/codegen-python.ts +1028 -0
  64. package/src/codegen-rust.ts +1652 -0
  65. package/src/codegen-sanitize.ts +67 -0
  66. package/src/codegen-schema-naming.ts +146 -0
  67. package/src/codegen.ts +5015 -0
  68. package/src/context.ts +210 -0
  69. package/src/cookie-sign.ts +58 -0
  70. package/src/cookie.ts +89 -0
  71. package/src/cors.ts +96 -0
  72. package/src/crypto.ts +19 -0
  73. package/src/csrf.ts +54 -0
  74. package/src/curl-logger.ts +235 -0
  75. package/src/detect-runtime.ts +19 -0
  76. package/src/error.ts +52 -0
  77. package/src/errors.ts +87 -0
  78. package/src/etag.ts +74 -0
  79. package/src/feature-load.ts +23 -0
  80. package/src/go-type-emitter.ts +404 -0
  81. package/src/honey-response.ts +354 -0
  82. package/src/i18n-slot.ts +20 -0
  83. package/src/i18n.ts +234 -0
  84. package/src/index.ts +3843 -0
  85. package/src/init.ts +149 -0
  86. package/src/input.ts +8 -0
  87. package/src/ip-restrict.ts +192 -0
  88. package/src/logger.ts +149 -0
  89. package/src/middleware.ts +199 -0
  90. package/src/node-request.ts +240 -0
  91. package/src/node.ts +212 -0
  92. package/src/openapi/register.ts +19 -0
  93. package/src/openapi/scalar.ts +33 -0
  94. package/src/openapi/spec-factory.ts +46 -0
  95. package/src/openapi/spec.ts +31 -0
  96. package/src/openapi/swagger.ts +34 -0
  97. package/src/plugin.ts +771 -0
  98. package/src/powered-by.ts +17 -0
  99. package/src/pretty-json.ts +39 -0
  100. package/src/proxy.ts +155 -0
  101. package/src/python-type-emitter.ts +144 -0
  102. package/src/realtime/buffer.ts +101 -0
  103. package/src/realtime/bus.ts +93 -0
  104. package/src/realtime/longpoll.ts +148 -0
  105. package/src/realtime/protocol.ts +128 -0
  106. package/src/realtime/route.ts +104 -0
  107. package/src/realtime/runtime.ts +430 -0
  108. package/src/request-id.ts +23 -0
  109. package/src/request-to-curl.ts +34 -0
  110. package/src/response.ts +414 -0
  111. package/src/rust-type-emitter.ts +412 -0
  112. package/src/secure-headers.ts +69 -0
  113. package/src/serve-register.ts +8 -0
  114. package/src/serve-slot.ts +20 -0
  115. package/src/serve.ts +141 -0
  116. package/src/server-timing.ts +66 -0
  117. package/src/static.ts +60 -0
  118. package/src/telemetry/otel.ts +139 -0
  119. package/src/testing.ts +114 -0
  120. package/src/timeout.ts +36 -0
  121. package/src/tree.ts +464 -0
  122. package/src/ts-type-emitter.ts +106 -0
  123. package/src/type-emitter.ts +524 -0
  124. package/src/type-extractor.ts +749 -0
  125. package/src/types.ts +638 -0
  126. package/src/validation.ts +300 -0
  127. package/src/ws/bun.ts +89 -0
  128. package/src/ws/cloudflare.ts +133 -0
  129. package/src/ws/deno.ts +109 -0
  130. package/src/ws/node.ts +161 -0
  131. package/src/yaml.ts +80 -0
@@ -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
@@ -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
+ }