@vibesdotdev/client 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/SPEC.md +107 -0
- package/dist/factory.d.ts +34 -0
- package/dist/factory.d.ts.map +1 -0
- package/dist/factory.js +80 -0
- package/dist/factory.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/client/core.d.ts +8 -0
- package/dist/lib/client/core.d.ts.map +1 -0
- package/dist/lib/client/core.js +5 -0
- package/dist/lib/client/core.js.map +1 -0
- package/dist/lib/client/internal/base-client.d.ts +19 -0
- package/dist/lib/client/internal/base-client.d.ts.map +1 -0
- package/dist/lib/client/internal/base-client.js +92 -0
- package/dist/lib/client/internal/base-client.js.map +1 -0
- package/dist/lib/client/internal/base-helpers.d.ts +9 -0
- package/dist/lib/client/internal/base-helpers.d.ts.map +1 -0
- package/dist/lib/client/internal/base-helpers.js +79 -0
- package/dist/lib/client/internal/base-helpers.js.map +1 -0
- package/dist/lib/client/internal/base-types.d.ts +35 -0
- package/dist/lib/client/internal/base-types.d.ts.map +1 -0
- package/dist/lib/client/internal/base-types.js +15 -0
- package/dist/lib/client/internal/base-types.js.map +1 -0
- package/dist/lib/client/internal/endpoint.d.ts +22 -0
- package/dist/lib/client/internal/endpoint.d.ts.map +1 -0
- package/dist/lib/client/internal/endpoint.js +35 -0
- package/dist/lib/client/internal/endpoint.js.map +1 -0
- package/dist/lib/client/internal/generator.d.ts +20 -0
- package/dist/lib/client/internal/generator.d.ts.map +1 -0
- package/dist/lib/client/internal/generator.js +173 -0
- package/dist/lib/client/internal/generator.js.map +1 -0
- package/dist/lib/client/internal/index.d.ts +5 -0
- package/dist/lib/client/internal/index.d.ts.map +1 -0
- package/dist/lib/client/internal/index.js +4 -0
- package/dist/lib/client/internal/index.js.map +1 -0
- package/dist/lib/client/internal/node/http2-fetch.node.d.ts +2 -0
- package/dist/lib/client/internal/node/http2-fetch.node.d.ts.map +1 -0
- package/dist/lib/client/internal/node/http2-fetch.node.js +131 -0
- package/dist/lib/client/internal/node/http2-fetch.node.js.map +1 -0
- package/dist/lib/client/internal/request-builder.d.ts +15 -0
- package/dist/lib/client/internal/request-builder.d.ts.map +1 -0
- package/dist/lib/client/internal/request-builder.js +158 -0
- package/dist/lib/client/internal/request-builder.js.map +1 -0
- package/dist/lib/client/internal/sse-stream.d.ts +23 -0
- package/dist/lib/client/internal/sse-stream.d.ts.map +1 -0
- package/dist/lib/client/internal/sse-stream.js +110 -0
- package/dist/lib/client/internal/sse-stream.js.map +1 -0
- package/dist/lib/client/internal/vibes-client.d.ts +32 -0
- package/dist/lib/client/internal/vibes-client.d.ts.map +1 -0
- package/dist/lib/client/internal/vibes-client.js +120 -0
- package/dist/lib/client/internal/vibes-client.js.map +1 -0
- package/dist/lib/client/internal/wrap-fetch.d.ts +6 -0
- package/dist/lib/client/internal/wrap-fetch.d.ts.map +1 -0
- package/dist/lib/client/internal/wrap-fetch.js +46 -0
- package/dist/lib/client/internal/wrap-fetch.js.map +1 -0
- package/dist/lib/client/node.d.ts +5 -0
- package/dist/lib/client/node.d.ts.map +1 -0
- package/dist/lib/client/node.js +33 -0
- package/dist/lib/client/node.js.map +1 -0
- package/dist/lib/client/types.d.ts +145 -0
- package/dist/lib/client/types.d.ts.map +1 -0
- package/dist/lib/client/types.js +21 -0
- package/dist/lib/client/types.js.map +1 -0
- package/dist/plugin.d.ts +19 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +80 -0
- package/dist/plugin.js.map +1 -0
- package/dist/schemas.d.ts +90 -0
- package/dist/schemas.d.ts.map +1 -0
- package/dist/schemas.js +9 -0
- package/dist/schemas.js.map +1 -0
- package/dist/sse-client.d.ts +39 -0
- package/dist/sse-client.d.ts.map +1 -0
- package/dist/sse-client.js +124 -0
- package/dist/sse-client.js.map +1 -0
- package/dist/tools/api-request/api-request.descriptor.d.ts +48 -0
- package/dist/tools/api-request/api-request.descriptor.d.ts.map +1 -0
- package/dist/tools/api-request/api-request.descriptor.js +27 -0
- package/dist/tools/api-request/api-request.descriptor.js.map +1 -0
- package/dist/tools/api-request/api-request.impl.consumer.d.ts +13 -0
- package/dist/tools/api-request/api-request.impl.consumer.d.ts.map +1 -0
- package/dist/tools/api-request/api-request.impl.consumer.js +51 -0
- package/dist/tools/api-request/api-request.impl.consumer.js.map +1 -0
- package/dist/tools/api-request/index.d.ts +5 -0
- package/dist/tools/api-request/index.d.ts.map +1 -0
- package/dist/tools/api-request/index.js +4 -0
- package/dist/tools/api-request/index.js.map +1 -0
- package/dist/tools/api-request/schemas/index.d.ts +33 -0
- package/dist/tools/api-request/schemas/index.d.ts.map +1 -0
- package/dist/tools/api-request/schemas/index.js +24 -0
- package/dist/tools/api-request/schemas/index.js.map +1 -0
- package/package.json +99 -0
- package/src/factory.ts +114 -0
- package/src/index.ts +15 -0
- package/src/lib/client/core.ts +13 -0
- package/src/lib/client/internal/base-client.ts +107 -0
- package/src/lib/client/internal/base-helpers.ts +74 -0
- package/src/lib/client/internal/base-types.ts +42 -0
- package/src/lib/client/internal/endpoint.ts +51 -0
- package/src/lib/client/internal/generator.ts +181 -0
- package/src/lib/client/internal/index.ts +4 -0
- package/src/lib/client/internal/node/http2-fetch.node.ts +138 -0
- package/src/lib/client/internal/request-builder.ts +147 -0
- package/src/lib/client/internal/sse-stream.ts +130 -0
- package/src/lib/client/internal/vibes-client.ts +167 -0
- package/src/lib/client/internal/wrap-fetch.ts +59 -0
- package/src/lib/client/node.ts +36 -0
- package/src/lib/client/types.ts +156 -0
- package/src/plugin.ts +104 -0
- package/src/schemas.ts +91 -0
- package/src/sse-client.ts +155 -0
- package/src/tools/api-request/api-request.descriptor.ts +28 -0
- package/src/tools/api-request/api-request.impl.consumer.ts +66 -0
- package/src/tools/api-request/index.ts +4 -0
- package/src/tools/api-request/schemas/index.ts +29 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import type { APIResource, PathInfo, RequestOptions } from '../types.ts';
|
|
2
|
+
import type { RequestBuilder } from './request-builder.ts';
|
|
3
|
+
|
|
4
|
+
export interface OpenAPISpec {
|
|
5
|
+
paths?: Record<string, Record<string, unknown>>;
|
|
6
|
+
[key: string]: unknown;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class APIGenerator {
|
|
10
|
+
constructor(private requestBuilder: RequestBuilder) {}
|
|
11
|
+
|
|
12
|
+
generateAPIs(
|
|
13
|
+
spec: OpenAPISpec
|
|
14
|
+
): Record<string, Record<string, (...args: unknown[]) => Promise<unknown>>> {
|
|
15
|
+
const resources = this.groupPathsByResource(spec);
|
|
16
|
+
const apis: Record<string, Record<string, (...args: unknown[]) => Promise<unknown>>> = {};
|
|
17
|
+
for (const resource of resources.values()) {
|
|
18
|
+
apis[resource.name] = this.createResourceAPI(resource);
|
|
19
|
+
}
|
|
20
|
+
return apis;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
private groupPathsByResource(spec: OpenAPISpec): Map<string, APIResource> {
|
|
24
|
+
const resources = new Map<string, APIResource>();
|
|
25
|
+
for (const [path, pathItem] of Object.entries(spec.paths || {})) {
|
|
26
|
+
if (!pathItem || typeof pathItem !== 'object') continue;
|
|
27
|
+
const resourceName = this.extractResourceName(path);
|
|
28
|
+
if (!resourceName) continue;
|
|
29
|
+
let resource = resources.get(resourceName);
|
|
30
|
+
if (!resource) {
|
|
31
|
+
resource = {
|
|
32
|
+
name: resourceName,
|
|
33
|
+
basePath: this.extractBasePath(path, resourceName),
|
|
34
|
+
operations: new Map()
|
|
35
|
+
};
|
|
36
|
+
resources.set(resourceName, resource);
|
|
37
|
+
}
|
|
38
|
+
const methods = ['get', 'post', 'put', 'patch', 'delete'] as const;
|
|
39
|
+
for (const method of methods) {
|
|
40
|
+
const operation = (pathItem as Record<string, unknown>)[method];
|
|
41
|
+
if (operation && typeof operation === 'object' && 'responses' in operation) {
|
|
42
|
+
const op = operation as Record<string, unknown>;
|
|
43
|
+
const pi = pathItem as Record<string, unknown>;
|
|
44
|
+
const opParams = (op.parameters || []) as Record<string, unknown>[];
|
|
45
|
+
const piParams = (pi.parameters || []) as Record<string, unknown>[];
|
|
46
|
+
const pathInfo: PathInfo = {
|
|
47
|
+
path,
|
|
48
|
+
method: method.toUpperCase(),
|
|
49
|
+
operationId: op.operationId as string | undefined,
|
|
50
|
+
parameters: [...piParams, ...opParams],
|
|
51
|
+
requestBody: op.requestBody as Record<string, unknown> | undefined,
|
|
52
|
+
responses: op.responses as Record<string, unknown>,
|
|
53
|
+
tags: op.tags as string[] | undefined,
|
|
54
|
+
summary: op.summary as string | undefined,
|
|
55
|
+
description: op.description as string | undefined
|
|
56
|
+
};
|
|
57
|
+
const operationName = this.getOperationName(pathInfo);
|
|
58
|
+
resource.operations.set(operationName, pathInfo);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return resources;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private extractResourceName(path: string): string | null {
|
|
66
|
+
const segments = path.split('/').filter(Boolean);
|
|
67
|
+
if (segments[0] !== 'api' || segments.length < 2) return null;
|
|
68
|
+
return this.toCamelCase(segments[1]);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private extractBasePath(path: string, resourceName: string): string {
|
|
72
|
+
const match = path.match(/^(\/api\/[^/]+)/);
|
|
73
|
+
return match ? match[1] : `/api/${resourceName}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private getOperationName(pathInfo: PathInfo): string {
|
|
77
|
+
const pathParts = pathInfo.path.split('/').filter(Boolean);
|
|
78
|
+
const method = pathInfo.method.toLowerCase();
|
|
79
|
+
const hasPathParam = pathInfo.path.includes('{');
|
|
80
|
+
const resourceName = pathParts[1];
|
|
81
|
+
if (method === 'get' && !hasPathParam) {
|
|
82
|
+
return pathInfo.operationId?.toLowerCase().includes('list') ? 'list' : 'list';
|
|
83
|
+
}
|
|
84
|
+
if (method === 'get' && hasPathParam) return 'get';
|
|
85
|
+
if (method === 'post' && !hasPathParam) return 'create';
|
|
86
|
+
if ((method === 'put' || method === 'patch') && hasPathParam) return 'update';
|
|
87
|
+
if (method === 'delete' && hasPathParam) return 'delete';
|
|
88
|
+
if (pathInfo.operationId) {
|
|
89
|
+
let opName = pathInfo.operationId;
|
|
90
|
+
if (resourceName && opName.toLowerCase().startsWith(resourceName.slice(0, -1))) {
|
|
91
|
+
opName = opName.slice(resourceName.length - 1);
|
|
92
|
+
}
|
|
93
|
+
return this.toCamelCase(opName);
|
|
94
|
+
}
|
|
95
|
+
const lastPart = pathParts[pathParts.length - 1];
|
|
96
|
+
if (!lastPart.startsWith('{')) return this.toCamelCase(lastPart);
|
|
97
|
+
return method;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private createResourceAPI(
|
|
101
|
+
resource: APIResource
|
|
102
|
+
): Record<string, (...args: unknown[]) => Promise<unknown>> {
|
|
103
|
+
const api: Record<string, (...args: unknown[]) => Promise<unknown>> = {};
|
|
104
|
+
for (const [name, pathInfo] of resource.operations) {
|
|
105
|
+
api[name] = this.createOperation(pathInfo);
|
|
106
|
+
}
|
|
107
|
+
this.addConvenienceMethods(api, resource);
|
|
108
|
+
return api;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private createOperation(pathInfo: PathInfo): (...args: unknown[]) => Promise<unknown> {
|
|
112
|
+
return (...args: unknown[]) => {
|
|
113
|
+
const options = this.buildRequestOptions(pathInfo, args);
|
|
114
|
+
return this.requestBuilder.request(options);
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private buildRequestOptions(pathInfo: PathInfo, args: unknown[]): RequestOptions {
|
|
119
|
+
const options: RequestOptions = { method: pathInfo.method, path: pathInfo.path };
|
|
120
|
+
const params: Record<string, unknown> = {};
|
|
121
|
+
const query: Record<string, unknown> = {};
|
|
122
|
+
let body: unknown;
|
|
123
|
+
let argIndex = 0;
|
|
124
|
+
if (pathInfo.parameters) {
|
|
125
|
+
for (const param of pathInfo.parameters) {
|
|
126
|
+
if ((param as Record<string, unknown>).in === 'path' && argIndex < args.length) {
|
|
127
|
+
params[(param as Record<string, unknown>).name as string] = args[argIndex++];
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (argIndex < args.length) {
|
|
132
|
+
const nextArg = args[argIndex];
|
|
133
|
+
if (typeof nextArg === 'object' && nextArg !== null) {
|
|
134
|
+
if (pathInfo.method === 'GET' || pathInfo.method === 'DELETE') {
|
|
135
|
+
Object.assign(query, nextArg);
|
|
136
|
+
} else {
|
|
137
|
+
body = nextArg;
|
|
138
|
+
argIndex++;
|
|
139
|
+
if (argIndex < args.length && typeof args[argIndex] === 'object') {
|
|
140
|
+
Object.assign(query, args[argIndex]);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (Object.keys(params).length > 0) options.params = params as Record<string, string | number | boolean>;
|
|
146
|
+
if (Object.keys(query).length > 0) options.query = query as Record<string, string | number | boolean | string[] | undefined>;
|
|
147
|
+
if (body !== undefined) options.body = body;
|
|
148
|
+
return options;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private addConvenienceMethods(
|
|
152
|
+
api: Record<string, (...args: unknown[]) => Promise<unknown>>,
|
|
153
|
+
_resource: APIResource
|
|
154
|
+
): void {
|
|
155
|
+
if (api.list && !api.search) {
|
|
156
|
+
api.search = ((query: string, params?: Record<string, unknown>) => {
|
|
157
|
+
return api.list({ ...params, search: query });
|
|
158
|
+
}) as (...args: unknown[]) => Promise<unknown>;
|
|
159
|
+
}
|
|
160
|
+
if (api.get && api.create && !api.getOrCreate) {
|
|
161
|
+
api.getOrCreate = (async (id: string, data: unknown) => {
|
|
162
|
+
try {
|
|
163
|
+
return await api.get(id);
|
|
164
|
+
} catch (error: unknown) {
|
|
165
|
+
const status = (error as Record<string, unknown>).status;
|
|
166
|
+
if (status === 404) {
|
|
167
|
+
const record = data as Record<string, unknown>;
|
|
168
|
+
return await api.create(Object.assign({}, record, { id }));
|
|
169
|
+
}
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
}) as (...args: unknown[]) => Promise<unknown>;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private toCamelCase(str: string): string {
|
|
177
|
+
return str
|
|
178
|
+
.replace(/[-_](.)/g, (_, char) => char.toUpperCase())
|
|
179
|
+
.replace(/^(.)/, (_, char) => char.toLowerCase());
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { getRuntimeEnv } from '@vibesdotdev/runtime';
|
|
2
|
+
import { connect as http2Connect, type ClientHttp2Session } from 'node:http2';
|
|
3
|
+
|
|
4
|
+
export function createHttp2Fetch(
|
|
5
|
+
baseUrl: string
|
|
6
|
+
): (input: Parameters<typeof fetch>[0], init?: RequestInit) => Promise<Response> {
|
|
7
|
+
const sessions = new Map<string, ClientHttp2Session>();
|
|
8
|
+
const rejectUnauthorized = getRuntimeEnv('REJECT_UNAUTHORIZED') !== 'false';
|
|
9
|
+
|
|
10
|
+
return (input: Parameters<typeof fetch>[0], init?: RequestInit): Promise<Response> => {
|
|
11
|
+
const url =
|
|
12
|
+
typeof input === 'string'
|
|
13
|
+
? new URL(input, baseUrl)
|
|
14
|
+
: input instanceof URL
|
|
15
|
+
? input
|
|
16
|
+
: new URL(input.url, baseUrl);
|
|
17
|
+
|
|
18
|
+
const method = init?.method?.toUpperCase() || 'GET';
|
|
19
|
+
const headers = new Headers(init?.headers);
|
|
20
|
+
const sessionKey = `${url.protocol}//${url.host}`;
|
|
21
|
+
let session = sessions.get(sessionKey);
|
|
22
|
+
|
|
23
|
+
if (!session || session.destroyed || session.closed) {
|
|
24
|
+
try {
|
|
25
|
+
session = http2Connect(url.origin, { rejectUnauthorized });
|
|
26
|
+
sessions.set(sessionKey, session);
|
|
27
|
+
session.on('close', () => sessions.delete(sessionKey));
|
|
28
|
+
session.on('error', (error) => {
|
|
29
|
+
if (sessions.has(sessionKey)) {
|
|
30
|
+
console.warn('[http2] Session error:', error.message);
|
|
31
|
+
}
|
|
32
|
+
sessions.delete(sessionKey);
|
|
33
|
+
});
|
|
34
|
+
} catch {
|
|
35
|
+
return fetch(url.toString(), init);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
try {
|
|
41
|
+
const http2Headers: Record<string, string> = {
|
|
42
|
+
':method': method,
|
|
43
|
+
':path': `${url.pathname}${url.search}`,
|
|
44
|
+
':scheme': url.protocol.replace(':', ''),
|
|
45
|
+
':authority': url.host
|
|
46
|
+
};
|
|
47
|
+
headers.forEach((value, key) => {
|
|
48
|
+
http2Headers[key.toLowerCase()] = value;
|
|
49
|
+
});
|
|
50
|
+
const req = session!.request(http2Headers);
|
|
51
|
+
let settled = false;
|
|
52
|
+
|
|
53
|
+
const abort = () => {
|
|
54
|
+
if (settled) return;
|
|
55
|
+
settled = true;
|
|
56
|
+
try {
|
|
57
|
+
req.close();
|
|
58
|
+
req.destroy();
|
|
59
|
+
} catch {
|
|
60
|
+
// ignore
|
|
61
|
+
}
|
|
62
|
+
const error = new Error('Request aborted');
|
|
63
|
+
error.name = 'AbortError';
|
|
64
|
+
reject(error);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const signal = init?.signal;
|
|
68
|
+
if (signal) {
|
|
69
|
+
if (signal.aborted) {
|
|
70
|
+
abort();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const cleanupAbort = () => {
|
|
77
|
+
if (!signal) return;
|
|
78
|
+
signal.removeEventListener('abort', abort);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
if (init?.body) {
|
|
82
|
+
if (typeof init.body === 'string') {
|
|
83
|
+
req.write(init.body);
|
|
84
|
+
} else if (init.body instanceof Buffer) {
|
|
85
|
+
req.write(init.body);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
req.end();
|
|
89
|
+
|
|
90
|
+
const chunks: Buffer[] = [];
|
|
91
|
+
const responseHeaders = new Headers();
|
|
92
|
+
let statusCode = 200;
|
|
93
|
+
|
|
94
|
+
req.on('response', (headers) => {
|
|
95
|
+
const statusValue = headers[':status'];
|
|
96
|
+
statusCode =
|
|
97
|
+
parseInt(Array.isArray(statusValue) ? statusValue[0] : String(statusValue ?? 200)) ||
|
|
98
|
+
200;
|
|
99
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
100
|
+
if (key.startsWith(':') || value === undefined) continue;
|
|
101
|
+
if (Array.isArray(value)) value.forEach((v) => responseHeaders.append(key, v));
|
|
102
|
+
else responseHeaders.set(key, String(value));
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
req.on('data', (chunk) => {
|
|
107
|
+
if (typeof chunk === 'string') {
|
|
108
|
+
chunks.push(Buffer.from(chunk));
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(new Uint8Array(chunk)));
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
req.on('end', () => {
|
|
115
|
+
if (settled) return;
|
|
116
|
+
settled = true;
|
|
117
|
+
cleanupAbort();
|
|
118
|
+
const body = Buffer.concat(chunks);
|
|
119
|
+
resolve(
|
|
120
|
+
new Response(body, {
|
|
121
|
+
status: statusCode,
|
|
122
|
+
headers: responseHeaders
|
|
123
|
+
})
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
req.on('error', (error) => {
|
|
128
|
+
if (settled) return;
|
|
129
|
+
settled = true;
|
|
130
|
+
cleanupAbort();
|
|
131
|
+
reject(error);
|
|
132
|
+
});
|
|
133
|
+
} catch (error) {
|
|
134
|
+
reject(error);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
};
|
|
138
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { ClientError, type AuthConfig, type RequestOptions } from '../types.ts';
|
|
2
|
+
|
|
3
|
+
export class RequestBuilder {
|
|
4
|
+
constructor(
|
|
5
|
+
private baseUrl: string,
|
|
6
|
+
private auth: AuthConfig,
|
|
7
|
+
private fetchImpl: typeof fetch,
|
|
8
|
+
private timeout: number = 30000,
|
|
9
|
+
private debug: boolean = false
|
|
10
|
+
) {}
|
|
11
|
+
|
|
12
|
+
async request<T = unknown>(options: RequestOptions): Promise<T> {
|
|
13
|
+
const url = this.buildUrl(options);
|
|
14
|
+
const headers = await this.buildHeaders(options);
|
|
15
|
+
const init: RequestInit = {
|
|
16
|
+
method: options.method,
|
|
17
|
+
headers,
|
|
18
|
+
signal: this.createAbortSignal(options.timeout || this.timeout)
|
|
19
|
+
};
|
|
20
|
+
if (options.body !== undefined) {
|
|
21
|
+
if (headers.get('Content-Type')?.includes('application/json'))
|
|
22
|
+
init.body = JSON.stringify(options.body);
|
|
23
|
+
else init.body = options.body as NonNullable<RequestInit['body']>;
|
|
24
|
+
}
|
|
25
|
+
const shouldLog = this.debug || url.pathname.includes('/auth/');
|
|
26
|
+
if (shouldLog) {
|
|
27
|
+
console.info('[Client] Request:', {
|
|
28
|
+
url: url.toString(),
|
|
29
|
+
method: options.method,
|
|
30
|
+
headers: Object.fromEntries(headers.entries()),
|
|
31
|
+
body: options.body
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
const response = await this.fetchImpl(url.toString(), init);
|
|
36
|
+
if (shouldLog) {
|
|
37
|
+
console.info('[Client] Response:', {
|
|
38
|
+
status: response.status,
|
|
39
|
+
statusText: response.statusText,
|
|
40
|
+
headers: Object.fromEntries(response.headers.entries())
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
if (!response.ok) throw await this.createError(response, options);
|
|
44
|
+
if (response.status === 204 || response.headers.get('Content-Length') === '0')
|
|
45
|
+
return undefined as T;
|
|
46
|
+
const contentType = response.headers.get('Content-Type');
|
|
47
|
+
if (contentType?.includes('application/json')) return (await response.json()) as T;
|
|
48
|
+
return (await response.text()) as T;
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (this.debug) {
|
|
51
|
+
console.error('[Client] Request failed:', {
|
|
52
|
+
url: url.toString(),
|
|
53
|
+
method: options.method,
|
|
54
|
+
error: error instanceof Error ? error.message : String(error),
|
|
55
|
+
errorName: error instanceof Error ? error.name : undefined
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
59
|
+
throw new ClientError('Request timeout', 408, undefined, options);
|
|
60
|
+
}
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private buildUrl(options: RequestOptions): URL {
|
|
66
|
+
let path = options.path;
|
|
67
|
+
if (options.params) {
|
|
68
|
+
for (const [key, value] of Object.entries(options.params)) {
|
|
69
|
+
path = path.replace(`{${key}}`, encodeURIComponent(String(value)));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const url = new URL(path, this.baseUrl);
|
|
73
|
+
if (options.query) {
|
|
74
|
+
for (const [key, value] of Object.entries(options.query)) {
|
|
75
|
+
if (value === undefined) continue;
|
|
76
|
+
if (Array.isArray(value)) value.forEach((v) => url.searchParams.append(key, String(v)));
|
|
77
|
+
else url.searchParams.set(key, String(value));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return url;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private async buildHeaders(options: RequestOptions): Promise<Headers> {
|
|
84
|
+
const headers = new Headers(options.headers);
|
|
85
|
+
if (!headers.has('Content-Type') && options.body !== undefined)
|
|
86
|
+
headers.set('Content-Type', 'application/json');
|
|
87
|
+
if (!headers.has('Accept')) headers.set('Accept', 'application/json');
|
|
88
|
+
if (this.auth.type !== 'none') {
|
|
89
|
+
const token = this.auth.credentials || (await this.auth.tokenProvider?.());
|
|
90
|
+
if (token) {
|
|
91
|
+
switch (this.auth.type) {
|
|
92
|
+
case 'apiKey':
|
|
93
|
+
case 'bearer':
|
|
94
|
+
headers.set('Authorization', `Bearer ${token}`);
|
|
95
|
+
break;
|
|
96
|
+
case 'session':
|
|
97
|
+
headers.set('Cookie', `session=${token}`);
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return headers;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private createAbortSignal(timeout: number): AbortSignal {
|
|
106
|
+
const controller = new AbortController();
|
|
107
|
+
setTimeout(() => controller.abort(), timeout);
|
|
108
|
+
return controller.signal;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private async createError(response: Response, request: RequestOptions): Promise<ClientError> {
|
|
112
|
+
let message = `${response.status} ${response.statusText}`;
|
|
113
|
+
try {
|
|
114
|
+
const contentType = response.headers.get('Content-Type');
|
|
115
|
+
if (contentType?.includes('application/json')) {
|
|
116
|
+
const error = await response.json();
|
|
117
|
+
const validationErrors =
|
|
118
|
+
(error as { issues?: unknown; errors?: unknown }).issues ??
|
|
119
|
+
(error as { errors?: unknown }).errors;
|
|
120
|
+
if (Array.isArray(validationErrors) && validationErrors.length > 0) {
|
|
121
|
+
const fieldErrors = validationErrors
|
|
122
|
+
.map((issue) => {
|
|
123
|
+
const rec = issue as { path?: unknown; message?: unknown };
|
|
124
|
+
const path =
|
|
125
|
+
Array.isArray(rec.path) && rec.path.length > 0
|
|
126
|
+
? rec.path.map(String).join('.')
|
|
127
|
+
: 'unknown';
|
|
128
|
+
return ` - ${path}: ${typeof rec.message === 'string' ? rec.message : 'Invalid'}`;
|
|
129
|
+
})
|
|
130
|
+
.join('\n');
|
|
131
|
+
message = `Validation failed:\n${fieldErrors}`;
|
|
132
|
+
} else if (typeof (error as { message?: unknown }).message === 'string') {
|
|
133
|
+
message = (error as { message: string }).message;
|
|
134
|
+
} else if ((error as { error?: unknown }).error !== undefined) {
|
|
135
|
+
const e = (error as { error: unknown }).error;
|
|
136
|
+
message = typeof e === 'string' ? e : JSON.stringify(e);
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
const text = await response.text();
|
|
140
|
+
if (text) message = text;
|
|
141
|
+
}
|
|
142
|
+
} catch {
|
|
143
|
+
// ignore parsing errors
|
|
144
|
+
}
|
|
145
|
+
return new ClientError(message, response.status, response, request);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { type AuthConfig, type RequestOptions, ClientError } from '../types.ts';
|
|
2
|
+
|
|
3
|
+
export interface SSEEvent<T = unknown> {
|
|
4
|
+
event?: string;
|
|
5
|
+
data: T;
|
|
6
|
+
id?: string;
|
|
7
|
+
retry?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type SSEStreamOptions = {
|
|
11
|
+
method?: string;
|
|
12
|
+
params?: Record<string, string | number | boolean>;
|
|
13
|
+
query?: Record<string, string | number | boolean | string[] | undefined>;
|
|
14
|
+
body?: unknown;
|
|
15
|
+
headers?: Record<string, string>;
|
|
16
|
+
timeout?: number;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export async function* streamSSE<T = unknown>(args: {
|
|
20
|
+
baseUrl: string;
|
|
21
|
+
auth: AuthConfig;
|
|
22
|
+
fetchImpl: typeof fetch;
|
|
23
|
+
path: string;
|
|
24
|
+
options?: SSEStreamOptions;
|
|
25
|
+
}): AsyncIterableIterator<SSEEvent<T>> {
|
|
26
|
+
const options = args.options ?? {};
|
|
27
|
+
let url = `${args.baseUrl}${args.path}`;
|
|
28
|
+
if (options.params) {
|
|
29
|
+
for (const [key, value] of Object.entries(options.params)) {
|
|
30
|
+
url = url.replace(`:${key}`, String(value));
|
|
31
|
+
url = url.replace(`{${key}}`, String(value));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (options.query) {
|
|
35
|
+
const searchParams = new URLSearchParams();
|
|
36
|
+
for (const [key, value] of Object.entries(options.query)) {
|
|
37
|
+
if (value === undefined || value === null) continue;
|
|
38
|
+
if (Array.isArray(value)) value.forEach((v) => searchParams.append(key, String(v)));
|
|
39
|
+
else searchParams.append(key, String(value));
|
|
40
|
+
}
|
|
41
|
+
const queryString = searchParams.toString();
|
|
42
|
+
if (queryString) url += `?${queryString}`;
|
|
43
|
+
}
|
|
44
|
+
const headers: Record<string, string> = { Accept: 'text/event-stream', ...options.headers };
|
|
45
|
+
if (!('Content-Type' in headers)) headers['Content-Type'] = 'application/json';
|
|
46
|
+
if (args.auth.type === 'apiKey' && args.auth.credentials) {
|
|
47
|
+
headers['X-API-Key'] = args.auth.credentials;
|
|
48
|
+
} else if (args.auth.type === 'bearer') {
|
|
49
|
+
const token = args.auth.tokenProvider ? await args.auth.tokenProvider() : args.auth.credentials;
|
|
50
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
51
|
+
}
|
|
52
|
+
const requestOptions: RequestOptions = {
|
|
53
|
+
method: options.method || 'POST',
|
|
54
|
+
path: args.path,
|
|
55
|
+
params: options.params,
|
|
56
|
+
query: options.query,
|
|
57
|
+
body: options.body,
|
|
58
|
+
headers: options.headers,
|
|
59
|
+
timeout: options.timeout
|
|
60
|
+
};
|
|
61
|
+
const requestInit: RequestInit = { method: requestOptions.method, headers };
|
|
62
|
+
if (requestOptions.timeout) {
|
|
63
|
+
const controller = new AbortController();
|
|
64
|
+
setTimeout(() => controller.abort(), requestOptions.timeout);
|
|
65
|
+
requestInit.signal = controller.signal;
|
|
66
|
+
}
|
|
67
|
+
if (options.body !== undefined) requestInit.body = JSON.stringify(options.body);
|
|
68
|
+
const response = await args.fetchImpl(url, requestInit);
|
|
69
|
+
if (!response.ok) {
|
|
70
|
+
throw new ClientError(
|
|
71
|
+
`SSE stream failed: ${response.statusText}`,
|
|
72
|
+
response.status,
|
|
73
|
+
response,
|
|
74
|
+
requestOptions
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
if (!response.body) {
|
|
78
|
+
throw new ClientError(
|
|
79
|
+
'No response body for SSE stream',
|
|
80
|
+
response.status,
|
|
81
|
+
response,
|
|
82
|
+
requestOptions
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
const reader = response.body.getReader();
|
|
86
|
+
const decoder = new TextDecoder();
|
|
87
|
+
let buffer = '';
|
|
88
|
+
try {
|
|
89
|
+
while (true) {
|
|
90
|
+
const { done, value } = await reader.read();
|
|
91
|
+
if (done) break;
|
|
92
|
+
buffer += decoder.decode(value, { stream: true });
|
|
93
|
+
const lines = buffer.split('\n');
|
|
94
|
+
buffer = lines.pop() || '';
|
|
95
|
+
let currentEvent: Partial<SSEEvent<T>> = {};
|
|
96
|
+
for (const line of lines) {
|
|
97
|
+
if (line.trim() === '') {
|
|
98
|
+
if (currentEvent.data !== undefined) {
|
|
99
|
+
yield currentEvent as SSEEvent<T>;
|
|
100
|
+
currentEvent = {};
|
|
101
|
+
}
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (line.startsWith('event:')) {
|
|
105
|
+
currentEvent.event = line.slice(6).trim();
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (line.startsWith('data:')) {
|
|
109
|
+
const dataStr = line.slice(5).trim();
|
|
110
|
+
if (dataStr === '[DONE]') return;
|
|
111
|
+
try {
|
|
112
|
+
currentEvent.data = JSON.parse(dataStr) as T;
|
|
113
|
+
} catch {
|
|
114
|
+
currentEvent.data = dataStr as T;
|
|
115
|
+
}
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (line.startsWith('id:')) {
|
|
119
|
+
currentEvent.id = line.slice(3).trim();
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (line.startsWith('retry:')) {
|
|
123
|
+
currentEvent.retry = parseInt(line.slice(6).trim());
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
} finally {
|
|
128
|
+
reader.releaseLock();
|
|
129
|
+
}
|
|
130
|
+
}
|