@topolo/sdk 0.1.1 → 0.1.2
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/dist/index.cjs +50 -2
- package/dist/index.d.cts +37 -1
- package/dist/index.d.ts +37 -1
- package/dist/index.js +50 -2
- package/package.json +1 -1
- package/src/client.test.ts +84 -0
- package/src/client.ts +92 -2
- package/src/index.ts +1 -0
package/dist/index.cjs
CHANGED
|
@@ -119,6 +119,7 @@ var TopoloClient = class {
|
|
|
119
119
|
requireConfirmForWrites;
|
|
120
120
|
timeoutMs;
|
|
121
121
|
fetchImpl;
|
|
122
|
+
debug;
|
|
122
123
|
constructor(options) {
|
|
123
124
|
if (!options.credential) throw new TopoloAuthError("credential is required");
|
|
124
125
|
if (!options.agent?.clientName) throw new TopoloAuthError("agent.clientName is required");
|
|
@@ -128,6 +129,14 @@ var TopoloClient = class {
|
|
|
128
129
|
this.requireConfirmForWrites = options.requireConfirmForWrites !== false;
|
|
129
130
|
this.timeoutMs = options.timeoutMs ?? 3e4;
|
|
130
131
|
this.fetchImpl = options.fetch ?? fetch;
|
|
132
|
+
this.debug = options.debug;
|
|
133
|
+
}
|
|
134
|
+
emit(event) {
|
|
135
|
+
if (!this.debug) return;
|
|
136
|
+
try {
|
|
137
|
+
this.debug(event);
|
|
138
|
+
} catch {
|
|
139
|
+
}
|
|
131
140
|
}
|
|
132
141
|
/**
|
|
133
142
|
* Low-level JSON request. Prefer the typed module helpers (identity, crm, ...)
|
|
@@ -154,27 +163,56 @@ var TopoloClient = class {
|
|
|
154
163
|
const platformServiceId = PLATFORM_SERVICE_IDS[opts.service];
|
|
155
164
|
if (platformServiceId) headers.set("X-Service-ID", platformServiceId);
|
|
156
165
|
applyAuthHeaders(headers, this.credential);
|
|
157
|
-
|
|
166
|
+
const requestId = generateRequestId();
|
|
167
|
+
applyAuditHeaders(headers, this.agent, requestId);
|
|
158
168
|
if (opts.headers) {
|
|
159
169
|
for (const [k, v] of Object.entries(opts.headers)) headers.set(k, v);
|
|
160
170
|
}
|
|
161
171
|
const controller = new AbortController();
|
|
162
172
|
const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
163
173
|
const signal = opts.signal ? mergeSignals(opts.signal, controller.signal) : controller.signal;
|
|
174
|
+
const urlStr = url.toString();
|
|
175
|
+
const startedAt = Date.now();
|
|
176
|
+
this.emit({ phase: "request", method, service: opts.service, path: opts.path, url: urlStr, requestId });
|
|
164
177
|
let res;
|
|
165
178
|
try {
|
|
166
|
-
res = await this.fetchImpl(
|
|
179
|
+
res = await this.fetchImpl(urlStr, {
|
|
167
180
|
method,
|
|
168
181
|
headers,
|
|
169
182
|
body: opts.body !== void 0 ? JSON.stringify(opts.body) : null,
|
|
170
183
|
signal
|
|
171
184
|
});
|
|
185
|
+
} catch (err) {
|
|
186
|
+
this.emit({
|
|
187
|
+
phase: "error",
|
|
188
|
+
method,
|
|
189
|
+
service: opts.service,
|
|
190
|
+
path: opts.path,
|
|
191
|
+
url: urlStr,
|
|
192
|
+
requestId,
|
|
193
|
+
durationMs: Date.now() - startedAt,
|
|
194
|
+
error: err instanceof Error ? err.message : String(err)
|
|
195
|
+
});
|
|
196
|
+
throw err;
|
|
172
197
|
} finally {
|
|
173
198
|
clearTimeout(timeoutId);
|
|
174
199
|
}
|
|
175
200
|
const contentType = res.headers.get("Content-Type") ?? "";
|
|
176
201
|
const parsed = contentType.includes("application/json") ? await res.json().catch(() => null) : await res.text().catch(() => null);
|
|
177
202
|
if (!res.ok) {
|
|
203
|
+
const durationMs = Date.now() - startedAt;
|
|
204
|
+
const message = describeError(parsed, `HTTP ${res.status}`);
|
|
205
|
+
this.emit({
|
|
206
|
+
phase: "error",
|
|
207
|
+
method,
|
|
208
|
+
service: opts.service,
|
|
209
|
+
path: opts.path,
|
|
210
|
+
url: urlStr,
|
|
211
|
+
requestId,
|
|
212
|
+
durationMs,
|
|
213
|
+
error: message,
|
|
214
|
+
status: res.status
|
|
215
|
+
});
|
|
178
216
|
if (res.status === 401) throw new TopoloAuthError(describeError(parsed, "Unauthorized"));
|
|
179
217
|
if (res.status === 403) {
|
|
180
218
|
throw new TopoloPermissionError(
|
|
@@ -184,6 +222,16 @@ var TopoloClient = class {
|
|
|
184
222
|
}
|
|
185
223
|
throw new TopoloHttpError(opts.service, opts.path, res.status, parsed);
|
|
186
224
|
}
|
|
225
|
+
this.emit({
|
|
226
|
+
phase: "response",
|
|
227
|
+
method,
|
|
228
|
+
service: opts.service,
|
|
229
|
+
path: opts.path,
|
|
230
|
+
url: urlStr,
|
|
231
|
+
requestId,
|
|
232
|
+
status: res.status,
|
|
233
|
+
durationMs: Date.now() - startedAt
|
|
234
|
+
});
|
|
187
235
|
return parsed;
|
|
188
236
|
}
|
|
189
237
|
/**
|
package/dist/index.d.cts
CHANGED
|
@@ -65,7 +65,41 @@ interface TopoloClientOptions {
|
|
|
65
65
|
timeoutMs?: number;
|
|
66
66
|
/** Injected fetch, for testing. Defaults to global fetch. */
|
|
67
67
|
fetch?: typeof fetch;
|
|
68
|
+
/**
|
|
69
|
+
* Observability hook. Fires once per request lifecycle (`request` at start,
|
|
70
|
+
* then exactly one of `response` or `error`). Safe to leave unset — off by
|
|
71
|
+
* default. Intended for CLI/MCP hosts to surface request-level diagnostics
|
|
72
|
+
* and for consumers building their own logging/tracing.
|
|
73
|
+
*/
|
|
74
|
+
debug?: (event: TopoloDebugEvent) => void;
|
|
68
75
|
}
|
|
76
|
+
type TopoloDebugEvent = {
|
|
77
|
+
phase: 'request';
|
|
78
|
+
method: string;
|
|
79
|
+
service: ServiceId;
|
|
80
|
+
path: string;
|
|
81
|
+
url: string;
|
|
82
|
+
requestId: string;
|
|
83
|
+
} | {
|
|
84
|
+
phase: 'response';
|
|
85
|
+
method: string;
|
|
86
|
+
service: ServiceId;
|
|
87
|
+
path: string;
|
|
88
|
+
url: string;
|
|
89
|
+
requestId: string;
|
|
90
|
+
status: number;
|
|
91
|
+
durationMs: number;
|
|
92
|
+
} | {
|
|
93
|
+
phase: 'error';
|
|
94
|
+
method: string;
|
|
95
|
+
service: ServiceId;
|
|
96
|
+
path: string;
|
|
97
|
+
url: string;
|
|
98
|
+
requestId: string;
|
|
99
|
+
durationMs: number;
|
|
100
|
+
error: string;
|
|
101
|
+
status?: number;
|
|
102
|
+
};
|
|
69
103
|
interface RequestOptions {
|
|
70
104
|
service: ServiceId;
|
|
71
105
|
path: string;
|
|
@@ -85,7 +119,9 @@ declare class TopoloClient {
|
|
|
85
119
|
private readonly requireConfirmForWrites;
|
|
86
120
|
private readonly timeoutMs;
|
|
87
121
|
private readonly fetchImpl;
|
|
122
|
+
private readonly debug?;
|
|
88
123
|
constructor(options: TopoloClientOptions);
|
|
124
|
+
private emit;
|
|
89
125
|
/**
|
|
90
126
|
* Low-level JSON request. Prefer the typed module helpers (identity, crm, ...)
|
|
91
127
|
* for anything a caller would reach for; this stays exported for escape-hatch
|
|
@@ -274,4 +310,4 @@ declare function createTopolo(options: TopoloClientOptions): {
|
|
|
274
310
|
};
|
|
275
311
|
type Topolo = ReturnType<typeof createTopolo>;
|
|
276
312
|
|
|
277
|
-
export { type AgentIdentity, type CredentialIntrospection, type CrmContactSummary, CrmModule, DEFAULT_SERVICE_URLS, type DeviceAuthorizationResponse, IdentityModule, type ListContactsOptions, type ListContactsResult, type OAuthHelperOptions, PLATFORM_SERVICE_IDS, type RequestOptions, type ServiceId, type TokenResponse, type Topolo, TopoloAuthError, TopoloClient, type TopoloClientOptions, type TopoloCredential, TopoloHttpError, TopoloOAuth, TopoloPermissionError, TopoloSdkError, createTopolo, resolveServiceUrl };
|
|
313
|
+
export { type AgentIdentity, type CredentialIntrospection, type CrmContactSummary, CrmModule, DEFAULT_SERVICE_URLS, type DeviceAuthorizationResponse, IdentityModule, type ListContactsOptions, type ListContactsResult, type OAuthHelperOptions, PLATFORM_SERVICE_IDS, type RequestOptions, type ServiceId, type TokenResponse, type Topolo, TopoloAuthError, TopoloClient, type TopoloClientOptions, type TopoloCredential, type TopoloDebugEvent, TopoloHttpError, TopoloOAuth, TopoloPermissionError, TopoloSdkError, createTopolo, resolveServiceUrl };
|
package/dist/index.d.ts
CHANGED
|
@@ -65,7 +65,41 @@ interface TopoloClientOptions {
|
|
|
65
65
|
timeoutMs?: number;
|
|
66
66
|
/** Injected fetch, for testing. Defaults to global fetch. */
|
|
67
67
|
fetch?: typeof fetch;
|
|
68
|
+
/**
|
|
69
|
+
* Observability hook. Fires once per request lifecycle (`request` at start,
|
|
70
|
+
* then exactly one of `response` or `error`). Safe to leave unset — off by
|
|
71
|
+
* default. Intended for CLI/MCP hosts to surface request-level diagnostics
|
|
72
|
+
* and for consumers building their own logging/tracing.
|
|
73
|
+
*/
|
|
74
|
+
debug?: (event: TopoloDebugEvent) => void;
|
|
68
75
|
}
|
|
76
|
+
type TopoloDebugEvent = {
|
|
77
|
+
phase: 'request';
|
|
78
|
+
method: string;
|
|
79
|
+
service: ServiceId;
|
|
80
|
+
path: string;
|
|
81
|
+
url: string;
|
|
82
|
+
requestId: string;
|
|
83
|
+
} | {
|
|
84
|
+
phase: 'response';
|
|
85
|
+
method: string;
|
|
86
|
+
service: ServiceId;
|
|
87
|
+
path: string;
|
|
88
|
+
url: string;
|
|
89
|
+
requestId: string;
|
|
90
|
+
status: number;
|
|
91
|
+
durationMs: number;
|
|
92
|
+
} | {
|
|
93
|
+
phase: 'error';
|
|
94
|
+
method: string;
|
|
95
|
+
service: ServiceId;
|
|
96
|
+
path: string;
|
|
97
|
+
url: string;
|
|
98
|
+
requestId: string;
|
|
99
|
+
durationMs: number;
|
|
100
|
+
error: string;
|
|
101
|
+
status?: number;
|
|
102
|
+
};
|
|
69
103
|
interface RequestOptions {
|
|
70
104
|
service: ServiceId;
|
|
71
105
|
path: string;
|
|
@@ -85,7 +119,9 @@ declare class TopoloClient {
|
|
|
85
119
|
private readonly requireConfirmForWrites;
|
|
86
120
|
private readonly timeoutMs;
|
|
87
121
|
private readonly fetchImpl;
|
|
122
|
+
private readonly debug?;
|
|
88
123
|
constructor(options: TopoloClientOptions);
|
|
124
|
+
private emit;
|
|
89
125
|
/**
|
|
90
126
|
* Low-level JSON request. Prefer the typed module helpers (identity, crm, ...)
|
|
91
127
|
* for anything a caller would reach for; this stays exported for escape-hatch
|
|
@@ -274,4 +310,4 @@ declare function createTopolo(options: TopoloClientOptions): {
|
|
|
274
310
|
};
|
|
275
311
|
type Topolo = ReturnType<typeof createTopolo>;
|
|
276
312
|
|
|
277
|
-
export { type AgentIdentity, type CredentialIntrospection, type CrmContactSummary, CrmModule, DEFAULT_SERVICE_URLS, type DeviceAuthorizationResponse, IdentityModule, type ListContactsOptions, type ListContactsResult, type OAuthHelperOptions, PLATFORM_SERVICE_IDS, type RequestOptions, type ServiceId, type TokenResponse, type Topolo, TopoloAuthError, TopoloClient, type TopoloClientOptions, type TopoloCredential, TopoloHttpError, TopoloOAuth, TopoloPermissionError, TopoloSdkError, createTopolo, resolveServiceUrl };
|
|
313
|
+
export { type AgentIdentity, type CredentialIntrospection, type CrmContactSummary, CrmModule, DEFAULT_SERVICE_URLS, type DeviceAuthorizationResponse, IdentityModule, type ListContactsOptions, type ListContactsResult, type OAuthHelperOptions, PLATFORM_SERVICE_IDS, type RequestOptions, type ServiceId, type TokenResponse, type Topolo, TopoloAuthError, TopoloClient, type TopoloClientOptions, type TopoloCredential, type TopoloDebugEvent, TopoloHttpError, TopoloOAuth, TopoloPermissionError, TopoloSdkError, createTopolo, resolveServiceUrl };
|
package/dist/index.js
CHANGED
|
@@ -82,6 +82,7 @@ var TopoloClient = class {
|
|
|
82
82
|
requireConfirmForWrites;
|
|
83
83
|
timeoutMs;
|
|
84
84
|
fetchImpl;
|
|
85
|
+
debug;
|
|
85
86
|
constructor(options) {
|
|
86
87
|
if (!options.credential) throw new TopoloAuthError("credential is required");
|
|
87
88
|
if (!options.agent?.clientName) throw new TopoloAuthError("agent.clientName is required");
|
|
@@ -91,6 +92,14 @@ var TopoloClient = class {
|
|
|
91
92
|
this.requireConfirmForWrites = options.requireConfirmForWrites !== false;
|
|
92
93
|
this.timeoutMs = options.timeoutMs ?? 3e4;
|
|
93
94
|
this.fetchImpl = options.fetch ?? fetch;
|
|
95
|
+
this.debug = options.debug;
|
|
96
|
+
}
|
|
97
|
+
emit(event) {
|
|
98
|
+
if (!this.debug) return;
|
|
99
|
+
try {
|
|
100
|
+
this.debug(event);
|
|
101
|
+
} catch {
|
|
102
|
+
}
|
|
94
103
|
}
|
|
95
104
|
/**
|
|
96
105
|
* Low-level JSON request. Prefer the typed module helpers (identity, crm, ...)
|
|
@@ -117,27 +126,56 @@ var TopoloClient = class {
|
|
|
117
126
|
const platformServiceId = PLATFORM_SERVICE_IDS[opts.service];
|
|
118
127
|
if (platformServiceId) headers.set("X-Service-ID", platformServiceId);
|
|
119
128
|
applyAuthHeaders(headers, this.credential);
|
|
120
|
-
|
|
129
|
+
const requestId = generateRequestId();
|
|
130
|
+
applyAuditHeaders(headers, this.agent, requestId);
|
|
121
131
|
if (opts.headers) {
|
|
122
132
|
for (const [k, v] of Object.entries(opts.headers)) headers.set(k, v);
|
|
123
133
|
}
|
|
124
134
|
const controller = new AbortController();
|
|
125
135
|
const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
126
136
|
const signal = opts.signal ? mergeSignals(opts.signal, controller.signal) : controller.signal;
|
|
137
|
+
const urlStr = url.toString();
|
|
138
|
+
const startedAt = Date.now();
|
|
139
|
+
this.emit({ phase: "request", method, service: opts.service, path: opts.path, url: urlStr, requestId });
|
|
127
140
|
let res;
|
|
128
141
|
try {
|
|
129
|
-
res = await this.fetchImpl(
|
|
142
|
+
res = await this.fetchImpl(urlStr, {
|
|
130
143
|
method,
|
|
131
144
|
headers,
|
|
132
145
|
body: opts.body !== void 0 ? JSON.stringify(opts.body) : null,
|
|
133
146
|
signal
|
|
134
147
|
});
|
|
148
|
+
} catch (err) {
|
|
149
|
+
this.emit({
|
|
150
|
+
phase: "error",
|
|
151
|
+
method,
|
|
152
|
+
service: opts.service,
|
|
153
|
+
path: opts.path,
|
|
154
|
+
url: urlStr,
|
|
155
|
+
requestId,
|
|
156
|
+
durationMs: Date.now() - startedAt,
|
|
157
|
+
error: err instanceof Error ? err.message : String(err)
|
|
158
|
+
});
|
|
159
|
+
throw err;
|
|
135
160
|
} finally {
|
|
136
161
|
clearTimeout(timeoutId);
|
|
137
162
|
}
|
|
138
163
|
const contentType = res.headers.get("Content-Type") ?? "";
|
|
139
164
|
const parsed = contentType.includes("application/json") ? await res.json().catch(() => null) : await res.text().catch(() => null);
|
|
140
165
|
if (!res.ok) {
|
|
166
|
+
const durationMs = Date.now() - startedAt;
|
|
167
|
+
const message = describeError(parsed, `HTTP ${res.status}`);
|
|
168
|
+
this.emit({
|
|
169
|
+
phase: "error",
|
|
170
|
+
method,
|
|
171
|
+
service: opts.service,
|
|
172
|
+
path: opts.path,
|
|
173
|
+
url: urlStr,
|
|
174
|
+
requestId,
|
|
175
|
+
durationMs,
|
|
176
|
+
error: message,
|
|
177
|
+
status: res.status
|
|
178
|
+
});
|
|
141
179
|
if (res.status === 401) throw new TopoloAuthError(describeError(parsed, "Unauthorized"));
|
|
142
180
|
if (res.status === 403) {
|
|
143
181
|
throw new TopoloPermissionError(
|
|
@@ -147,6 +185,16 @@ var TopoloClient = class {
|
|
|
147
185
|
}
|
|
148
186
|
throw new TopoloHttpError(opts.service, opts.path, res.status, parsed);
|
|
149
187
|
}
|
|
188
|
+
this.emit({
|
|
189
|
+
phase: "response",
|
|
190
|
+
method,
|
|
191
|
+
service: opts.service,
|
|
192
|
+
path: opts.path,
|
|
193
|
+
url: urlStr,
|
|
194
|
+
requestId,
|
|
195
|
+
status: res.status,
|
|
196
|
+
durationMs: Date.now() - startedAt
|
|
197
|
+
});
|
|
150
198
|
return parsed;
|
|
151
199
|
}
|
|
152
200
|
/**
|
package/package.json
CHANGED
package/src/client.test.ts
CHANGED
|
@@ -167,5 +167,89 @@ describe('TopoloClient.request', () => {
|
|
|
167
167
|
});
|
|
168
168
|
});
|
|
169
169
|
|
|
170
|
+
describe('TopoloClient.debug hook', () => {
|
|
171
|
+
it('fires request → response on a successful call with stable requestId + duration', async () => {
|
|
172
|
+
const { impl } = stubFetch([{ body: { ok: true } }]);
|
|
173
|
+
const events: unknown[] = [];
|
|
174
|
+
const client = new TopoloClient({
|
|
175
|
+
credential: { kind: 'api_key', apiKey: 'x' },
|
|
176
|
+
agent: AGENT,
|
|
177
|
+
fetch: impl,
|
|
178
|
+
debug: (e) => events.push(e),
|
|
179
|
+
});
|
|
180
|
+
await client.request({ service: 'auth', path: '/api/me' });
|
|
181
|
+
expect(events).toHaveLength(2);
|
|
182
|
+
const typed = events as Array<{
|
|
183
|
+
phase: string;
|
|
184
|
+
method: string;
|
|
185
|
+
service: string;
|
|
186
|
+
path: string;
|
|
187
|
+
url: string;
|
|
188
|
+
requestId: string;
|
|
189
|
+
status?: number;
|
|
190
|
+
durationMs?: number;
|
|
191
|
+
}>;
|
|
192
|
+
const req = typed[0]!;
|
|
193
|
+
const res = typed[1]!;
|
|
194
|
+
expect(req.phase).toBe('request');
|
|
195
|
+
expect(req.method).toBe('GET');
|
|
196
|
+
expect(req.service).toBe('auth');
|
|
197
|
+
expect(req.path).toBe('/api/me');
|
|
198
|
+
expect(req.requestId).toMatch(/.+/);
|
|
199
|
+
expect(res.phase).toBe('response');
|
|
200
|
+
expect(res.status).toBe(200);
|
|
201
|
+
expect(res.requestId).toBe(req.requestId);
|
|
202
|
+
expect(typeof res.durationMs).toBe('number');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('fires request → error on 4xx with the unwrapped message and status', async () => {
|
|
206
|
+
const { impl } = stubFetch([
|
|
207
|
+
{ status: 401, body: { error: { code: 'auth_error', message: 'Expired' } } },
|
|
208
|
+
]);
|
|
209
|
+
const events: Array<{ phase: string; error?: string; status?: number }> = [];
|
|
210
|
+
const client = new TopoloClient({
|
|
211
|
+
credential: { kind: 'api_key', apiKey: 'x' },
|
|
212
|
+
agent: AGENT,
|
|
213
|
+
fetch: impl,
|
|
214
|
+
debug: (e) => events.push(e),
|
|
215
|
+
});
|
|
216
|
+
await expect(client.request({ service: 'auth', path: '/api/me' })).rejects.toThrow(TopoloAuthError);
|
|
217
|
+
expect(events.map((e) => e.phase)).toEqual(['request', 'error']);
|
|
218
|
+
expect(events[1]!.status).toBe(401);
|
|
219
|
+
expect(events[1]!.error).toBe('Expired');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('fires request → error on a thrown fetch (network failure)', async () => {
|
|
223
|
+
const impl = (vi.fn(async () => {
|
|
224
|
+
throw new Error('ECONNREFUSED');
|
|
225
|
+
}) as unknown) as typeof fetch;
|
|
226
|
+
const events: Array<{ phase: string; error?: string; status?: number }> = [];
|
|
227
|
+
const client = new TopoloClient({
|
|
228
|
+
credential: { kind: 'api_key', apiKey: 'x' },
|
|
229
|
+
agent: AGENT,
|
|
230
|
+
fetch: impl,
|
|
231
|
+
debug: (e) => events.push(e),
|
|
232
|
+
});
|
|
233
|
+
await expect(client.request({ service: 'auth', path: '/api/me' })).rejects.toThrow('ECONNREFUSED');
|
|
234
|
+
expect(events.map((e) => e.phase)).toEqual(['request', 'error']);
|
|
235
|
+
expect(events[1]!.error).toBe('ECONNREFUSED');
|
|
236
|
+
expect(events[1]!.status).toBeUndefined();
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('swallows throws from the debug callback without affecting the request path', async () => {
|
|
240
|
+
const { impl } = stubFetch([{ body: { ok: true } }]);
|
|
241
|
+
const client = new TopoloClient({
|
|
242
|
+
credential: { kind: 'api_key', apiKey: 'x' },
|
|
243
|
+
agent: AGENT,
|
|
244
|
+
fetch: impl,
|
|
245
|
+
debug: () => {
|
|
246
|
+
throw new Error('callback blew up');
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
// Returns normally despite the callback throwing on both events.
|
|
250
|
+
await expect(client.request({ service: 'auth', path: '/api/me' })).resolves.toBeDefined();
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
170
254
|
// Silence unused-import warning if the bundler later tree-shakes these
|
|
171
255
|
void TopoloHttpError;
|
package/src/client.ts
CHANGED
|
@@ -26,8 +26,46 @@ export interface TopoloClientOptions {
|
|
|
26
26
|
timeoutMs?: number;
|
|
27
27
|
/** Injected fetch, for testing. Defaults to global fetch. */
|
|
28
28
|
fetch?: typeof fetch;
|
|
29
|
+
/**
|
|
30
|
+
* Observability hook. Fires once per request lifecycle (`request` at start,
|
|
31
|
+
* then exactly one of `response` or `error`). Safe to leave unset — off by
|
|
32
|
+
* default. Intended for CLI/MCP hosts to surface request-level diagnostics
|
|
33
|
+
* and for consumers building their own logging/tracing.
|
|
34
|
+
*/
|
|
35
|
+
debug?: (event: TopoloDebugEvent) => void;
|
|
29
36
|
}
|
|
30
37
|
|
|
38
|
+
export type TopoloDebugEvent =
|
|
39
|
+
| {
|
|
40
|
+
phase: 'request';
|
|
41
|
+
method: string;
|
|
42
|
+
service: ServiceId;
|
|
43
|
+
path: string;
|
|
44
|
+
url: string;
|
|
45
|
+
requestId: string;
|
|
46
|
+
}
|
|
47
|
+
| {
|
|
48
|
+
phase: 'response';
|
|
49
|
+
method: string;
|
|
50
|
+
service: ServiceId;
|
|
51
|
+
path: string;
|
|
52
|
+
url: string;
|
|
53
|
+
requestId: string;
|
|
54
|
+
status: number;
|
|
55
|
+
durationMs: number;
|
|
56
|
+
}
|
|
57
|
+
| {
|
|
58
|
+
phase: 'error';
|
|
59
|
+
method: string;
|
|
60
|
+
service: ServiceId;
|
|
61
|
+
path: string;
|
|
62
|
+
url: string;
|
|
63
|
+
requestId: string;
|
|
64
|
+
durationMs: number;
|
|
65
|
+
error: string;
|
|
66
|
+
status?: number;
|
|
67
|
+
};
|
|
68
|
+
|
|
31
69
|
export interface RequestOptions {
|
|
32
70
|
service: ServiceId;
|
|
33
71
|
path: string;
|
|
@@ -50,6 +88,7 @@ export class TopoloClient {
|
|
|
50
88
|
private readonly requireConfirmForWrites: boolean;
|
|
51
89
|
private readonly timeoutMs: number;
|
|
52
90
|
private readonly fetchImpl: typeof fetch;
|
|
91
|
+
private readonly debug?: (event: TopoloDebugEvent) => void;
|
|
53
92
|
|
|
54
93
|
constructor(options: TopoloClientOptions) {
|
|
55
94
|
if (!options.credential) throw new TopoloAuthError('credential is required');
|
|
@@ -60,6 +99,16 @@ export class TopoloClient {
|
|
|
60
99
|
this.requireConfirmForWrites = options.requireConfirmForWrites !== false;
|
|
61
100
|
this.timeoutMs = options.timeoutMs ?? 30_000;
|
|
62
101
|
this.fetchImpl = options.fetch ?? fetch;
|
|
102
|
+
this.debug = options.debug;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private emit(event: TopoloDebugEvent): void {
|
|
106
|
+
if (!this.debug) return;
|
|
107
|
+
try {
|
|
108
|
+
this.debug(event);
|
|
109
|
+
} catch {
|
|
110
|
+
// Never let a debug consumer crash the request path.
|
|
111
|
+
}
|
|
63
112
|
}
|
|
64
113
|
|
|
65
114
|
/**
|
|
@@ -91,7 +140,8 @@ export class TopoloClient {
|
|
|
91
140
|
if (platformServiceId) headers.set('X-Service-ID', platformServiceId);
|
|
92
141
|
|
|
93
142
|
applyAuthHeaders(headers, this.credential);
|
|
94
|
-
|
|
143
|
+
const requestId = generateRequestId();
|
|
144
|
+
applyAuditHeaders(headers, this.agent, requestId);
|
|
95
145
|
|
|
96
146
|
if (opts.headers) {
|
|
97
147
|
for (const [k, v] of Object.entries(opts.headers)) headers.set(k, v);
|
|
@@ -103,14 +153,30 @@ export class TopoloClient {
|
|
|
103
153
|
? mergeSignals(opts.signal, controller.signal)
|
|
104
154
|
: controller.signal;
|
|
105
155
|
|
|
156
|
+
const urlStr = url.toString();
|
|
157
|
+
const startedAt = Date.now();
|
|
158
|
+
this.emit({ phase: 'request', method, service: opts.service, path: opts.path, url: urlStr, requestId });
|
|
159
|
+
|
|
106
160
|
let res: Response;
|
|
107
161
|
try {
|
|
108
|
-
res = await this.fetchImpl(
|
|
162
|
+
res = await this.fetchImpl(urlStr, {
|
|
109
163
|
method,
|
|
110
164
|
headers,
|
|
111
165
|
body: opts.body !== undefined ? JSON.stringify(opts.body) : null,
|
|
112
166
|
signal,
|
|
113
167
|
});
|
|
168
|
+
} catch (err) {
|
|
169
|
+
this.emit({
|
|
170
|
+
phase: 'error',
|
|
171
|
+
method,
|
|
172
|
+
service: opts.service,
|
|
173
|
+
path: opts.path,
|
|
174
|
+
url: urlStr,
|
|
175
|
+
requestId,
|
|
176
|
+
durationMs: Date.now() - startedAt,
|
|
177
|
+
error: err instanceof Error ? err.message : String(err),
|
|
178
|
+
});
|
|
179
|
+
throw err;
|
|
114
180
|
} finally {
|
|
115
181
|
clearTimeout(timeoutId);
|
|
116
182
|
}
|
|
@@ -121,6 +187,19 @@ export class TopoloClient {
|
|
|
121
187
|
: await res.text().catch(() => null);
|
|
122
188
|
|
|
123
189
|
if (!res.ok) {
|
|
190
|
+
const durationMs = Date.now() - startedAt;
|
|
191
|
+
const message = describeError(parsed, `HTTP ${res.status}`);
|
|
192
|
+
this.emit({
|
|
193
|
+
phase: 'error',
|
|
194
|
+
method,
|
|
195
|
+
service: opts.service,
|
|
196
|
+
path: opts.path,
|
|
197
|
+
url: urlStr,
|
|
198
|
+
requestId,
|
|
199
|
+
durationMs,
|
|
200
|
+
error: message,
|
|
201
|
+
status: res.status,
|
|
202
|
+
});
|
|
124
203
|
if (res.status === 401) throw new TopoloAuthError(describeError(parsed, 'Unauthorized'));
|
|
125
204
|
if (res.status === 403) {
|
|
126
205
|
throw new TopoloPermissionError(
|
|
@@ -131,6 +210,17 @@ export class TopoloClient {
|
|
|
131
210
|
throw new TopoloHttpError(opts.service, opts.path, res.status, parsed);
|
|
132
211
|
}
|
|
133
212
|
|
|
213
|
+
this.emit({
|
|
214
|
+
phase: 'response',
|
|
215
|
+
method,
|
|
216
|
+
service: opts.service,
|
|
217
|
+
path: opts.path,
|
|
218
|
+
url: urlStr,
|
|
219
|
+
requestId,
|
|
220
|
+
status: res.status,
|
|
221
|
+
durationMs: Date.now() - startedAt,
|
|
222
|
+
});
|
|
223
|
+
|
|
134
224
|
return parsed as T;
|
|
135
225
|
}
|
|
136
226
|
|