@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,502 @@
1
+ import { ClientError } from "./error.ts";
2
+ import type { SSEEvent } from "./sse.ts";
3
+ import { parseSSEStream } from "./sse.ts";
4
+
5
+ export type HeadersContext = {
6
+ method: string;
7
+ path: string;
8
+ };
9
+
10
+ type HeadersRecord = Record<string, string | undefined>;
11
+
12
+ export type HeadersInit =
13
+ | ((ctx: HeadersContext) => HeadersRecord | Promise<HeadersRecord>)
14
+ | HeadersRecord;
15
+
16
+ export type RequestMeta = {
17
+ invalidatedBy: string[]
18
+ isStale: boolean
19
+ selector: string
20
+ seqSnapshot: number
21
+ }
22
+
23
+ export type OnRequestContext = {
24
+ body?: BodyInit;
25
+ headers: Headers;
26
+ invalidatedBy?: string[]
27
+ isStale?: boolean
28
+ method: string;
29
+ path: string;
30
+ selector?: string
31
+ state: Record<string, unknown>;
32
+ url: string;
33
+ };
34
+
35
+ export type OnResponseContext = {
36
+ invalidatedBy?: string[]
37
+ isRetry: boolean;
38
+ isStale?: boolean
39
+ method: string;
40
+ path: string;
41
+ request: Request;
42
+ response: Response;
43
+ retry: () => Promise<Response>;
44
+ selector?: string
45
+ state: Record<string, unknown>;
46
+ url: string;
47
+ };
48
+
49
+ export type ClientConfig = {
50
+ baseURL: string;
51
+ buildSearchParams?: (query: Record<string, unknown>) => URLSearchParams;
52
+ credentials?: RequestCredentials;
53
+ fetch?: typeof fetch;
54
+ headers?: HeadersInit;
55
+ mode?: RequestMode;
56
+ onRequest?: Array<(ctx: OnRequestContext) => Promise<void> | void>;
57
+ onResponse?: Array<
58
+ (
59
+ ctx: OnResponseContext,
60
+ ) => Promise<Response | undefined> | Response | undefined
61
+ >;
62
+ sortSearchParams?: boolean;
63
+ state?: Record<string, unknown>;
64
+ throwOnError?: boolean;
65
+ timeout?: number;
66
+
67
+ /* Realtime hooks — called by the generated runtime module */
68
+ onAuthExpired?: () => Promise<string | null | undefined>;
69
+ onReconnecting?: (attempt: number, transport: string) => void;
70
+ onReconnected?: () => void;
71
+ };
72
+
73
+ export type RequestOptions = {
74
+ cookies?: Record<string, string>;
75
+ form?: Record<string, unknown>;
76
+ headers?: Record<string, string>;
77
+ json?: unknown;
78
+ lastEventId?: string;
79
+ params?: Record<string, string>;
80
+ search?: Record<string, unknown>;
81
+ signal?: AbortSignal;
82
+ };
83
+
84
+ export function interpolatePath(path: string, params: Record<string, string>): string {
85
+ return path.replace(/:(\w+)/g, (_, key: string) => {
86
+ const val = params[key];
87
+ if (val === undefined) throw new Error(`Missing path param: ${key}`);
88
+ return encodeURIComponent(val);
89
+ });
90
+ }
91
+
92
+ function coerceParam(v: unknown): string | null {
93
+ if (v === undefined || v === null) return null;
94
+ if (v instanceof Date) return v.toISOString();
95
+ if (typeof v === "symbol") return null;
96
+ return String(v);
97
+ }
98
+
99
+ function defaultSearchParams(query: Record<string, unknown>): URLSearchParams {
100
+ const params = new URLSearchParams();
101
+ for (const [k, v] of Object.entries(query)) {
102
+ if (v === undefined || v === null) continue;
103
+ if (Array.isArray(v)) {
104
+ for (const item of v) { const s = coerceParam(item); if (s !== null) params.append(k, s); }
105
+ } else {
106
+ const s = coerceParam(v); if (s !== null) params.set(k, s);
107
+ }
108
+ }
109
+ return params;
110
+ }
111
+
112
+ function applySearch(
113
+ url: URL,
114
+ search: Record<string, unknown>,
115
+ config: ClientConfig,
116
+ ): void {
117
+ const serializer = config.buildSearchParams ?? defaultSearchParams;
118
+ const params = serializer(search);
119
+ if (config.sortSearchParams) params.sort();
120
+ for (const [k, v] of params.entries()) {
121
+ url.searchParams.append(k, v);
122
+ }
123
+ }
124
+
125
+ function buildURL(
126
+ config: ClientConfig,
127
+ path: string,
128
+ opts: RequestOptions,
129
+ ): string {
130
+ let resolvedPath = path;
131
+ if (opts.params) resolvedPath = interpolatePath(path, opts.params);
132
+
133
+ const baseUrl = new URL(config.baseURL);
134
+ const basePath = baseUrl.pathname.endsWith("/") ? baseUrl.pathname : `${baseUrl.pathname}/`;
135
+ const relative = resolvedPath.startsWith("/") ? resolvedPath.slice(1) : resolvedPath;
136
+ const url = new URL(`${basePath}${relative}`, baseUrl);
137
+
138
+ for (const [k, v] of baseUrl.searchParams.entries()) url.searchParams.append(k, v);
139
+ if (opts.search) applySearch(url, opts.search, config);
140
+
141
+ return url.toString();
142
+ }
143
+
144
+ async function buildHeaders(
145
+ config: ClientConfig,
146
+ opts: RequestOptions,
147
+ ctx: HeadersContext,
148
+ ): Promise<Headers> {
149
+ const headers = new Headers();
150
+
151
+ if (config.headers) {
152
+ const resolved =
153
+ typeof config.headers === "function"
154
+ ? await config.headers(ctx)
155
+ : config.headers;
156
+ for (const [k, v] of Object.entries(resolved)) {
157
+ if (v !== undefined) headers.set(k, v);
158
+ }
159
+ }
160
+
161
+ /* per-request headers override config headers */
162
+ if (opts.headers) {
163
+ for (const [k, v] of Object.entries(opts.headers)) {
164
+ headers.set(k, v);
165
+ }
166
+ }
167
+
168
+ if (opts.cookies) {
169
+ const existing = headers.get("cookie");
170
+ const pairs = Object.entries(opts.cookies)
171
+ .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
172
+ .join("; ");
173
+ if (pairs) {
174
+ headers.set("cookie", existing ? `${existing}; ${pairs}` : pairs);
175
+ }
176
+ }
177
+
178
+ return headers;
179
+ }
180
+
181
+ function buildSignal(
182
+ config: ClientConfig,
183
+ opts: RequestOptions,
184
+ isStream?: boolean,
185
+ ): { signal: AbortSignal | undefined; cleanup: () => void } {
186
+ const userSignal = opts.signal;
187
+ /* skip timeout for streams — they're long-lived */
188
+ const timeout = isStream ? undefined : config.timeout;
189
+
190
+ if (!timeout) return { cleanup: () => {}, signal: userSignal };
191
+
192
+ const ctrl = new AbortController();
193
+ const timer = setTimeout(() => ctrl.abort(new Error("timeout")), timeout);
194
+ if (typeof (timer as unknown as { unref?: () => void }).unref === "function") {
195
+ (timer as unknown as { unref: () => void }).unref();
196
+ }
197
+ const onUserAbort = () => ctrl.abort(userSignal?.reason);
198
+ if (userSignal?.aborted) {
199
+ /* already aborted before we subscribed — propagate synchronously */
200
+ ctrl.abort(userSignal.reason);
201
+ } else {
202
+ userSignal?.addEventListener("abort", onUserAbort, { once: true });
203
+ }
204
+ return {
205
+ cleanup: () => {
206
+ clearTimeout(timer);
207
+ userSignal?.removeEventListener("abort", onUserAbort);
208
+ },
209
+ signal: ctrl.signal,
210
+ };
211
+ }
212
+
213
+ async function parseErrorBody(response: Response): Promise<unknown> {
214
+ try {
215
+ return await response.json();
216
+ } catch {
217
+ return undefined;
218
+ }
219
+ }
220
+
221
+ async function parseErrorAsClientError(response: Response): Promise<ClientError> {
222
+ const preserved = response.clone()
223
+ const body = await parseErrorBody(response)
224
+ const msgVal = typeof body === "object" && body !== null && "message" in body
225
+ ? (body as Record<string, unknown>).message
226
+ : undefined
227
+ const message = typeof msgVal === "string"
228
+ /* oxlint-disable-next-line no-control-regex -- intentional strip of ASCII control characters */
229
+ ? msgVal.replace(/[\x00-\x1f]/g, "").slice(0, 512)
230
+ : `HTTP ${response.status}`
231
+ return new ClientError({ body, message, response: preserved, status: response.status })
232
+ }
233
+
234
+ export class HTTPClient {
235
+ private _config: ClientConfig;
236
+ private _fetch: typeof fetch;
237
+
238
+ constructor(config: ClientConfig) {
239
+ this._config = config;
240
+ this._fetch = config.fetch ?? globalThis.fetch;
241
+ }
242
+
243
+ private async _doRequest(
244
+ method: string,
245
+ path: string,
246
+ opts: RequestOptions,
247
+ isRetry = false,
248
+ _requestMeta?: RequestMeta,
249
+ ): Promise<{
250
+ response: Response;
251
+ }> {
252
+ const url = buildURL(this._config, path, opts);
253
+ const headers = await buildHeaders(this._config, opts, { method, path });
254
+ let body: BodyInit | undefined;
255
+
256
+ if (opts.json !== undefined) {
257
+ headers.set("content-type", "application/json");
258
+ body = JSON.stringify(opts.json);
259
+ } else if (opts.form !== undefined) {
260
+ const hasFiles = Object.values(opts.form).some(
261
+ (v) =>
262
+ (typeof File !== "undefined" && v instanceof File) ||
263
+ (typeof Blob !== "undefined" && v instanceof Blob) ||
264
+ (typeof FileList !== "undefined" && v instanceof FileList) ||
265
+ (Array.isArray(v) &&
266
+ v.some(
267
+ (item) =>
268
+ (typeof File !== "undefined" && item instanceof File) ||
269
+ (typeof Blob !== "undefined" && item instanceof Blob),
270
+ )),
271
+ );
272
+
273
+ if (hasFiles) {
274
+ const fd = new FormData();
275
+ for (const [k, v] of Object.entries(opts.form)) {
276
+ if (v === undefined || v === null) continue;
277
+ if (typeof FileList !== "undefined" && v instanceof FileList) {
278
+ for (let i = 0; i < v.length; i++) { const f = v[i]; if (f) fd.append(k, f) }
279
+ } else if (Array.isArray(v)) {
280
+ for (const item of v) {
281
+ if (item instanceof File || item instanceof Blob)
282
+ fd.append(k, item);
283
+ else { const s = coerceParam(item); if (s !== null) fd.append(k, s); }
284
+ }
285
+ } else if (v instanceof File || v instanceof Blob) {
286
+ fd.append(k, v);
287
+ } else {
288
+ const s = coerceParam(v); if (s !== null) fd.append(k, s);
289
+ }
290
+ }
291
+ /* don't set content-type — browser sets multipart boundary */
292
+ body = fd;
293
+ } else {
294
+ headers.set("content-type", "application/x-www-form-urlencoded");
295
+ const params = new URLSearchParams();
296
+ for (const [k, v] of Object.entries(opts.form)) {
297
+ const s = coerceParam(v); if (s !== null) params.set(k, s);
298
+ }
299
+ body = params.toString();
300
+ }
301
+ }
302
+
303
+ /* onRequest interceptors */
304
+ if (this._config.onRequest) {
305
+ const reqCtx: OnRequestContext = { body, headers, method, path, state: this._config.state ?? {}, url }
306
+ if (_requestMeta) {
307
+ reqCtx.invalidatedBy = _requestMeta.invalidatedBy
308
+ reqCtx.isStale = _requestMeta.isStale
309
+ reqCtx.selector = _requestMeta.selector
310
+ }
311
+ for (const hook of this._config.onRequest) {
312
+ await hook(reqCtx);
313
+ }
314
+ if (reqCtx.body !== body) body = reqCtx.body;
315
+ }
316
+
317
+ const { signal, cleanup } = buildSignal(this._config, opts);
318
+ const init: RequestInit = { body, headers, method, signal };
319
+
320
+ if (this._config.credentials) init.credentials = this._config.credentials;
321
+ if (this._config.mode) init.mode = this._config.mode;
322
+
323
+ try {
324
+ /* fail fast if the signal is already aborted — matches native fetch semantics */
325
+ signal?.throwIfAborted();
326
+ let response = await this._fetch(url, init);
327
+
328
+ /* onResponse interceptors */
329
+ if (this._config.onResponse) {
330
+ const resCtx: OnResponseContext = {
331
+ isRetry,
332
+ method,
333
+ path,
334
+ request: new Request(url, init),
335
+ response,
336
+ retry: () => {
337
+ if (isRetry) throw new Error("Max 1 retry per request")
338
+ return this._doRequest(method, path, opts, true, _requestMeta).then(
339
+ async (r) => {
340
+ if (!r.response.ok) throw await parseErrorAsClientError(r.response)
341
+ return r.response
342
+ },
343
+ )
344
+ },
345
+ state: this._config.state ?? {},
346
+ url,
347
+ }
348
+ if (_requestMeta) {
349
+ resCtx.invalidatedBy = _requestMeta.invalidatedBy
350
+ resCtx.isStale = _requestMeta.isStale
351
+ resCtx.selector = _requestMeta.selector
352
+ }
353
+ for (const hook of this._config.onResponse) {
354
+ const result = await hook(resCtx);
355
+ if (result instanceof Response) {
356
+ response = result;
357
+ resCtx.response = result;
358
+ }
359
+ }
360
+ }
361
+
362
+ return { response };
363
+ } finally {
364
+ cleanup();
365
+ }
366
+ }
367
+
368
+ private _parseBody(response: Response): Promise<unknown> {
369
+ if (response.status === 204) return Promise.resolve(null)
370
+ const rawCt = response.headers.get("content-type") ?? ""
371
+ const ct = rawCt.split(";")[0]?.trim().toLowerCase() ?? ""
372
+ if (ct === "application/json" || ct.endsWith("+json")) {
373
+ return response.json()
374
+ }
375
+ if (ct === "application/octet-stream" || ct === "application/pdf") {
376
+ return response.arrayBuffer()
377
+ }
378
+ if (ct.startsWith("text/")) return response.text()
379
+ return response.arrayBuffer()
380
+ }
381
+
382
+ /** Throw mode — throws ClientError on non-2xx */
383
+ async request(
384
+ method: string,
385
+ path: string,
386
+ opts: RequestOptions,
387
+ _requestMeta?: RequestMeta,
388
+ ): Promise<unknown> {
389
+ const { response } = await this._doRequest(method, path, opts, false, _requestMeta);
390
+
391
+ if (!response.ok) {
392
+ throw await parseErrorAsClientError(response);
393
+ }
394
+
395
+ return this._parseBody(response);
396
+ }
397
+
398
+ /** Safe mode — returns { data, error, response, status } — error is the raw parsed body */
399
+ async requestSafe(
400
+ method: string,
401
+ path: string,
402
+ opts: RequestOptions,
403
+ _requestMeta?: RequestMeta,
404
+ ): Promise<{
405
+ data: unknown;
406
+ error: unknown;
407
+ response: Response;
408
+ status: number;
409
+ }> {
410
+ const { response } = await this._doRequest(method, path, opts, false, _requestMeta);
411
+
412
+ if (!response.ok) {
413
+ const preserved = response.clone();
414
+ const error = await parseErrorBody(response);
415
+ return {
416
+ data: null,
417
+ error,
418
+ response: preserved,
419
+ status: response.status,
420
+ };
421
+ }
422
+
423
+ let data: unknown;
424
+ try {
425
+ data = await this._parseBody(response);
426
+ } catch (e) {
427
+ return { data: null, error: e, response, status: response.status };
428
+ }
429
+ return { data, error: null, response, status: response.status };
430
+ }
431
+
432
+ requestStream(
433
+ method: string,
434
+ path: string,
435
+ opts: RequestOptions,
436
+ ): AsyncIterable<SSEEvent> {
437
+ return {
438
+ [Symbol.asyncIterator]: () => this._doStream(method, path, opts),
439
+ };
440
+ }
441
+
442
+ private async *_doStream(
443
+ method: string,
444
+ path: string,
445
+ opts: RequestOptions,
446
+ ): AsyncGenerator<SSEEvent, void, undefined> {
447
+ const url = buildURL(this._config, path, opts);
448
+ const headers = await buildHeaders(this._config, opts, { method, path });
449
+ headers.set("accept", "text/event-stream");
450
+
451
+ if (opts.lastEventId) {
452
+ headers.set("last-event-id", opts.lastEventId);
453
+ }
454
+
455
+ /* no timeout on streams — they're long-lived, but user abort still composes */
456
+ const { signal, cleanup } = buildSignal(this._config, opts, true);
457
+ const init: RequestInit = { headers, method, signal };
458
+
459
+ if (this._config.credentials) init.credentials = this._config.credentials;
460
+ if (this._config.mode) init.mode = this._config.mode;
461
+
462
+ try {
463
+ signal?.throwIfAborted();
464
+ const response = await this._fetch(url, init);
465
+
466
+ if (!response.ok) {
467
+ throw await parseErrorAsClientError(response);
468
+ }
469
+
470
+ if (!response.body) return;
471
+
472
+ yield* parseSSEStream(response.body);
473
+ } finally {
474
+ cleanup();
475
+ }
476
+ }
477
+
478
+ buildUrl(path: string, opts: RequestOptions): string {
479
+ return buildURL(this._config, path, opts);
480
+ }
481
+
482
+ buildPath(path: string, opts: RequestOptions): string {
483
+ let resolved = path;
484
+ if (opts.params) {
485
+ resolved = interpolatePath(path, opts.params);
486
+ }
487
+
488
+ if (!opts.search) return resolved;
489
+
490
+ const serializer = this._config.buildSearchParams ?? defaultSearchParams;
491
+ const params = serializer(opts.search);
492
+ if (this._config.sortSearchParams) params.sort();
493
+
494
+ const qs = params.toString();
495
+ return qs ? `${resolved}?${qs}` : resolved;
496
+ }
497
+
498
+ buildWSUrl(path: string, opts: RequestOptions): string {
499
+ const url = buildURL(this._config, path, opts);
500
+ return url.replace(/^https:\/\//, "wss://").replace(/^http:\/\//, "ws://");
501
+ }
502
+ }
@@ -0,0 +1,127 @@
1
+ import type { InferRoutes } from "../types.ts"
2
+ import { isClientError } from "./error.ts"
3
+ import type { ClientConfig } from "./http.ts"
4
+ import { HTTPClient } from "./http.ts"
5
+ import type { HoneyClient } from "./types.ts"
6
+ import type { TypedWebSocket } from "./ws.ts"
7
+ import { createTypedWebSocket } from "./ws.ts"
8
+
9
+ export type { ClientErrorInit } from "./error.ts"
10
+ export { ClientError, isClientError } from "./error.ts"
11
+ export type { ClientConfig, RequestOptions } from "./http.ts"
12
+ export { HTTPClient } from "./http.ts"
13
+ export type { SSEEvent } from "./sse.ts"
14
+ export { parseSSEStream } from "./sse.ts"
15
+ export type {
16
+ ClientInput,
17
+ ClientResult,
18
+ ErrorEnvelope,
19
+ ErrorsFor,
20
+ HoneyClient,
21
+ InputFor,
22
+ IsSSE,
23
+ OutputDefFor,
24
+ OutputFor,
25
+ PathsForMethod,
26
+ ReturnFor,
27
+ } from "./types.ts"
28
+ export type { TypedWebSocket } from "./ws.ts"
29
+ export { createTypedWebSocket } from "./ws.ts"
30
+
31
+ const HTTP_METHODS = new Set(["delete", "get", "patch", "post", "put"])
32
+
33
+ export function createClient<T>(
34
+ config: ClientConfig & { throwOnError: true },
35
+ ): HoneyClient<InferRoutes<T>, true>
36
+ export function createClient<T>(
37
+ config: Omit<ClientConfig, "throwOnError"> & { throwOnError?: false },
38
+ ): HoneyClient<InferRoutes<T>, false>
39
+ export function createClient<T, TThrow extends boolean>(
40
+ config: ClientConfig & { throwOnError?: TThrow },
41
+ ): HoneyClient<InferRoutes<T>, TThrow>
42
+ export function createClient<T, TThrow extends boolean = false>(
43
+ config: ClientConfig & { throwOnError?: TThrow },
44
+ ): HoneyClient<InferRoutes<T>, TThrow> {
45
+ const http = new HTTPClient(config)
46
+
47
+ return new Proxy({} as HoneyClient<InferRoutes<T>, TThrow>, {
48
+ get(_target, prop: string) {
49
+ if (prop === "$isClientError") return isClientError
50
+
51
+ if (prop === "$url") {
52
+ return (
53
+ path: string,
54
+ input?: { params?: Record<string, string>; search?: Record<string, unknown> },
55
+ ) => http.buildUrl(path, input ?? {})
56
+ }
57
+
58
+ if (prop === "$path") {
59
+ return (
60
+ path: string,
61
+ input?: { params?: Record<string, string>; search?: Record<string, unknown> },
62
+ ) => http.buildPath(path, input ?? {})
63
+ }
64
+
65
+ if (prop === "ws") {
66
+ return (
67
+ path: string,
68
+ input?: {
69
+ params?: Record<string, string>
70
+ protocols?: string | string[]
71
+ reconnectToken?: string
72
+ search?: Record<string, unknown>
73
+ },
74
+ ): TypedWebSocket => {
75
+ const opts = { params: input?.params, search: input?.search }
76
+ let url = http.buildWSUrl(path, opts)
77
+ if (input?.reconnectToken) {
78
+ const sep = url.includes("?") ? "&" : "?"
79
+ url = `${url}${sep}reconnect_token=${encodeURIComponent(input.reconnectToken)}`
80
+ }
81
+ return createTypedWebSocket(url, { protocols: input?.protocols })
82
+ }
83
+ }
84
+
85
+ if (HTTP_METHODS.has(prop)) {
86
+ return (path: string, input?: Record<string, unknown>) => {
87
+ const method = prop.toUpperCase()
88
+ const opts = input ?? {}
89
+ const shouldThrow = config.throwOnError === true
90
+
91
+ /**
92
+ * Lazy dual-mode: both PromiseLike (REST) and AsyncIterable (SSE).
93
+ * - `await api.get(...)` → REST (request or requestSafe)
94
+ * - `for await (... of api.get(...))` → SSE (requestStream)
95
+ */
96
+ let mode: "idle" | "rest" | "sse" = "idle"
97
+ let restPromise: Promise<unknown> | undefined
98
+ const lazyRest = (): Promise<unknown> => {
99
+ if (!restPromise) {
100
+ restPromise = shouldThrow
101
+ ? http.request(method, path, opts)
102
+ : http.requestSafe(method, path, opts)
103
+ }
104
+ return restPromise
105
+ }
106
+
107
+ const lazy = new Promise((resolve, reject) => {
108
+ queueMicrotask(() => {
109
+ if (mode === "sse") return
110
+ mode = "rest"
111
+ lazyRest().then(resolve, reject)
112
+ })
113
+ })
114
+ Object.defineProperty(lazy, Symbol.asyncIterator, {
115
+ value() {
116
+ mode = "sse"
117
+ return http.requestStream(method, path, opts)[Symbol.asyncIterator]()
118
+ },
119
+ })
120
+ return lazy
121
+ }
122
+ }
123
+
124
+ return undefined
125
+ },
126
+ })
127
+ }