@nuxtseo/sdk 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/dist/client.d.ts +99 -0
- package/dist/client.js +308 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +28 -0
- package/dist/pairing.d.ts +26 -0
- package/dist/pairing.js +52 -0
- package/dist/transport.d.ts +42 -0
- package/dist/transport.js +114 -0
- package/package.json +48 -0
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { OperationInput, OperationOutput, PublicV1ErrorCode, PublicV1OperationDefinition, PublicV1ProtocolLike } from '@nuxtseo/protocol/v1/core';
|
|
2
|
+
import type { PublicApiTransport, TransportFailure } from './transport.js';
|
|
3
|
+
export interface RateLimitMetadata {
|
|
4
|
+
policy: string;
|
|
5
|
+
limit?: number;
|
|
6
|
+
windowSeconds?: number;
|
|
7
|
+
remaining?: number;
|
|
8
|
+
resetAfterSeconds?: number;
|
|
9
|
+
}
|
|
10
|
+
export interface RetryMetadata {
|
|
11
|
+
afterMilliseconds: number;
|
|
12
|
+
}
|
|
13
|
+
export interface LifecycleMetadata {
|
|
14
|
+
deprecatedAt?: string;
|
|
15
|
+
sunsetAt?: string;
|
|
16
|
+
}
|
|
17
|
+
export interface PublicResponseMetadata {
|
|
18
|
+
requestId?: string;
|
|
19
|
+
version?: string;
|
|
20
|
+
operationId?: string;
|
|
21
|
+
rateLimit?: RateLimitMetadata;
|
|
22
|
+
retry?: RetryMetadata;
|
|
23
|
+
lifecycle?: LifecycleMetadata;
|
|
24
|
+
}
|
|
25
|
+
export type PublicSdkResult<TValue, TError> = {
|
|
26
|
+
_tag: 'Ok';
|
|
27
|
+
value: TValue;
|
|
28
|
+
metadata?: PublicResponseMetadata;
|
|
29
|
+
} | {
|
|
30
|
+
_tag: 'Err';
|
|
31
|
+
error: TError;
|
|
32
|
+
};
|
|
33
|
+
export interface RequestFailure {
|
|
34
|
+
_tag: 'RequestFailure';
|
|
35
|
+
operationId: string;
|
|
36
|
+
location: 'body' | 'headers' | 'input' | 'params' | 'query';
|
|
37
|
+
issues: readonly unknown[];
|
|
38
|
+
}
|
|
39
|
+
export interface ApiFailure {
|
|
40
|
+
_tag: 'ApiFailure';
|
|
41
|
+
operationId: string;
|
|
42
|
+
code: PublicV1ErrorCode;
|
|
43
|
+
message: string;
|
|
44
|
+
status: number;
|
|
45
|
+
requestId: string;
|
|
46
|
+
retryable: boolean;
|
|
47
|
+
details: Readonly<Record<string, unknown>>;
|
|
48
|
+
response: PublicApiErrorResponse;
|
|
49
|
+
metadata: PublicResponseMetadata;
|
|
50
|
+
}
|
|
51
|
+
export interface PublicApiErrorResponse {
|
|
52
|
+
error: {
|
|
53
|
+
code: PublicV1ErrorCode;
|
|
54
|
+
message: string;
|
|
55
|
+
requestId: string;
|
|
56
|
+
retryable: boolean;
|
|
57
|
+
details: Readonly<Record<string, unknown>>;
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
export interface ContractFailure {
|
|
61
|
+
_tag: 'ContractFailure';
|
|
62
|
+
operationId: string;
|
|
63
|
+
status: number;
|
|
64
|
+
requestId?: string;
|
|
65
|
+
message: string;
|
|
66
|
+
issues: readonly unknown[];
|
|
67
|
+
metadata: PublicResponseMetadata;
|
|
68
|
+
}
|
|
69
|
+
export type SdkFailure = RequestFailure | ApiFailure | ContractFailure | TransportFailure;
|
|
70
|
+
export interface ExecuteOptions {
|
|
71
|
+
requestId?: string;
|
|
72
|
+
signal?: AbortSignal;
|
|
73
|
+
}
|
|
74
|
+
export type OperationExecuteOptions<TOperation extends PublicV1OperationDefinition> = ExecuteOptions & (TOperation['policy']['idempotency'] extends {
|
|
75
|
+
_tag: 'required';
|
|
76
|
+
} ? {
|
|
77
|
+
idempotencyKey: string;
|
|
78
|
+
} : {
|
|
79
|
+
idempotencyKey?: never;
|
|
80
|
+
});
|
|
81
|
+
export interface RetryOptions {
|
|
82
|
+
maxAttempts?: number;
|
|
83
|
+
baseDelayMs?: number;
|
|
84
|
+
maxDelayMs?: number;
|
|
85
|
+
sleep?: (milliseconds: number, signal?: AbortSignal) => Promise<void>;
|
|
86
|
+
}
|
|
87
|
+
export interface CreateSdkClientOptions<TProtocol extends PublicV1ProtocolLike> {
|
|
88
|
+
protocol: TProtocol;
|
|
89
|
+
transport: PublicApiTransport;
|
|
90
|
+
retry?: RetryOptions;
|
|
91
|
+
}
|
|
92
|
+
export interface SdkClient {
|
|
93
|
+
execute: <const TOperation extends PublicV1OperationDefinition>(operation: TOperation, input: OperationInput<TOperation>, options?: OperationExecuteOptions<TOperation>) => Promise<PublicSdkResult<OperationOutput<TOperation>, SdkFailure>>;
|
|
94
|
+
}
|
|
95
|
+
export type DomainClient<TOperations extends Readonly<Record<string, PublicV1OperationDefinition>>> = {
|
|
96
|
+
[TKey in keyof TOperations]: (input: OperationInput<TOperations[TKey]>, options?: OperationExecuteOptions<TOperations[TKey]>) => Promise<PublicSdkResult<OperationOutput<TOperations[TKey]>, SdkFailure>>;
|
|
97
|
+
};
|
|
98
|
+
export declare function createSdkClient<const TProtocol extends PublicV1ProtocolLike>(options: CreateSdkClientOptions<TProtocol>): SdkClient;
|
|
99
|
+
export declare function createDomainClient<const TOperations extends Readonly<Record<string, PublicV1OperationDefinition>>>(client: SdkClient, operations: TOperations): DomainClient<TOperations>;
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { buildOperationPath, listOperations, PUBLIC_V1_ERROR_STATUS, PUBLIC_V1_RESPONSE_HEADERS } from '@nuxtseo/protocol/v1/core';
|
|
2
|
+
function ok(value) {
|
|
3
|
+
return { _tag: 'Ok', value };
|
|
4
|
+
}
|
|
5
|
+
function err(error) {
|
|
6
|
+
return { _tag: 'Err', error };
|
|
7
|
+
}
|
|
8
|
+
function defaultSleep(milliseconds, signal) {
|
|
9
|
+
if (signal?.aborted)
|
|
10
|
+
return Promise.reject(signal.reason ?? new DOMException('The operation was aborted.', 'AbortError'));
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
const handle = setTimeout(() => {
|
|
13
|
+
signal?.removeEventListener('abort', onAbort);
|
|
14
|
+
resolve();
|
|
15
|
+
}, milliseconds);
|
|
16
|
+
function onAbort() {
|
|
17
|
+
clearTimeout(handle);
|
|
18
|
+
reject(signal?.reason ?? new DOMException('The operation was aborted.', 'AbortError'));
|
|
19
|
+
}
|
|
20
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
function resolveRetryOptions(options) {
|
|
24
|
+
const maxAttempts = options?.maxAttempts ?? 3;
|
|
25
|
+
const baseDelayMs = options?.baseDelayMs ?? 100;
|
|
26
|
+
const maxDelayMs = options?.maxDelayMs ?? 2_000;
|
|
27
|
+
if (!Number.isInteger(maxAttempts) || maxAttempts < 1)
|
|
28
|
+
throw new TypeError('retry.maxAttempts must be a positive integer.');
|
|
29
|
+
if (baseDelayMs < 0 || maxDelayMs < baseDelayMs)
|
|
30
|
+
throw new TypeError('retry delays must be nonnegative and maxDelayMs must be at least baseDelayMs.');
|
|
31
|
+
return { maxAttempts, baseDelayMs, maxDelayMs, sleep: options?.sleep ?? defaultSleep };
|
|
32
|
+
}
|
|
33
|
+
function requestFailure(operation, location, issues) {
|
|
34
|
+
return err({ _tag: 'RequestFailure', operationId: operation.id, location, issues });
|
|
35
|
+
}
|
|
36
|
+
function parseLocation(operation, location, input) {
|
|
37
|
+
const schema = operation.request[location];
|
|
38
|
+
const supplied = Object.hasOwn(input, location) && input[location] !== undefined;
|
|
39
|
+
if (schema === null) {
|
|
40
|
+
return supplied
|
|
41
|
+
? requestFailure(operation, location, [`${location} is not accepted by this operation.`])
|
|
42
|
+
: ok(undefined);
|
|
43
|
+
}
|
|
44
|
+
const value = supplied ? input[location] : location === 'body' ? undefined : {};
|
|
45
|
+
const parsed = schema.safeParse(value);
|
|
46
|
+
return parsed.success
|
|
47
|
+
? ok(parsed.data)
|
|
48
|
+
: requestFailure(operation, location, parsed.error.issues);
|
|
49
|
+
}
|
|
50
|
+
function prepareRequest(operation, input) {
|
|
51
|
+
if (typeof input !== 'object' || input === null || Array.isArray(input))
|
|
52
|
+
return requestFailure(operation, 'input', ['input must be an object.']);
|
|
53
|
+
const source = input;
|
|
54
|
+
const accepted = new Set(['params', 'query', 'headers', 'body']);
|
|
55
|
+
const extra = Object.keys(source).filter(key => !accepted.has(key));
|
|
56
|
+
if (extra.length > 0)
|
|
57
|
+
return requestFailure(operation, 'input', [`Unknown input keys: ${extra.join(', ')}`]);
|
|
58
|
+
const params = parseLocation(operation, 'params', source);
|
|
59
|
+
if (params._tag === 'Err')
|
|
60
|
+
return params;
|
|
61
|
+
const query = parseLocation(operation, 'query', source);
|
|
62
|
+
if (query._tag === 'Err')
|
|
63
|
+
return query;
|
|
64
|
+
const headers = parseLocation(operation, 'headers', source);
|
|
65
|
+
if (headers._tag === 'Err')
|
|
66
|
+
return headers;
|
|
67
|
+
const body = parseLocation(operation, 'body', source);
|
|
68
|
+
if (body._tag === 'Err')
|
|
69
|
+
return body;
|
|
70
|
+
return ok({
|
|
71
|
+
path: buildOperationPath(operation, params.value),
|
|
72
|
+
query: (query.value ?? {}),
|
|
73
|
+
headers: new Headers((headers.value ?? {})),
|
|
74
|
+
body: body.value,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
function appendQuery(path, query) {
|
|
78
|
+
const search = new URLSearchParams();
|
|
79
|
+
for (const key of Object.keys(query).sort()) {
|
|
80
|
+
const value = query[key];
|
|
81
|
+
const values = Array.isArray(value) ? value : [value];
|
|
82
|
+
for (const item of values) {
|
|
83
|
+
if (item === undefined)
|
|
84
|
+
continue;
|
|
85
|
+
if (item === null) {
|
|
86
|
+
search.append(key, '');
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (typeof item === 'object') {
|
|
90
|
+
search.append(key, JSON.stringify(item));
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
search.append(key, String(item));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const serialized = search.toString();
|
|
97
|
+
return serialized ? `${path}?${serialized}` : path;
|
|
98
|
+
}
|
|
99
|
+
function makeTransportRequest(operation, prepared, options) {
|
|
100
|
+
const headers = new Headers(prepared.headers);
|
|
101
|
+
headers.set('accept', 'application/json');
|
|
102
|
+
if (options.requestId)
|
|
103
|
+
headers.set('x-request-id', options.requestId);
|
|
104
|
+
if (options.idempotencyKey)
|
|
105
|
+
headers.set('idempotency-key', options.idempotencyKey);
|
|
106
|
+
const body = prepared.body === undefined ? undefined : JSON.stringify(prepared.body);
|
|
107
|
+
if (body !== undefined)
|
|
108
|
+
headers.set('content-type', 'application/json');
|
|
109
|
+
return {
|
|
110
|
+
operationId: operation.id,
|
|
111
|
+
method: operation.method,
|
|
112
|
+
path: appendQuery(prepared.path, prepared.query),
|
|
113
|
+
headers,
|
|
114
|
+
body,
|
|
115
|
+
signal: options.signal,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
function parseRetryAfter(value) {
|
|
119
|
+
if (value === null)
|
|
120
|
+
return undefined;
|
|
121
|
+
const seconds = Number(value);
|
|
122
|
+
if (Number.isFinite(seconds) && seconds >= 0)
|
|
123
|
+
return Math.ceil(seconds * 1_000);
|
|
124
|
+
const date = Date.parse(value);
|
|
125
|
+
return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now());
|
|
126
|
+
}
|
|
127
|
+
function integerParameter(value, name) {
|
|
128
|
+
const match = value.match(new RegExp(`(?:^|;)${name}=(\\d+)(?:;|$)`));
|
|
129
|
+
if (!match)
|
|
130
|
+
return undefined;
|
|
131
|
+
const parsed = Number(match[1]);
|
|
132
|
+
return Number.isSafeInteger(parsed) ? parsed : undefined;
|
|
133
|
+
}
|
|
134
|
+
function parseRateLimitMetadata(headers) {
|
|
135
|
+
const policyValue = headers.get(PUBLIC_V1_RESPONSE_HEADERS.rateLimitPolicy);
|
|
136
|
+
const rateValue = headers.get(PUBLIC_V1_RESPONSE_HEADERS.rateLimit);
|
|
137
|
+
const value = rateValue ?? policyValue;
|
|
138
|
+
if (!value)
|
|
139
|
+
return undefined;
|
|
140
|
+
const policy = value.match(/^"([^"]+)"/)?.[1];
|
|
141
|
+
if (!policy)
|
|
142
|
+
return undefined;
|
|
143
|
+
return {
|
|
144
|
+
policy,
|
|
145
|
+
...(policyValue && integerParameter(policyValue, 'q') !== undefined ? { limit: integerParameter(policyValue, 'q') } : {}),
|
|
146
|
+
...(policyValue && integerParameter(policyValue, 'w') !== undefined ? { windowSeconds: integerParameter(policyValue, 'w') } : {}),
|
|
147
|
+
...(rateValue && integerParameter(rateValue, 'r') !== undefined ? { remaining: integerParameter(rateValue, 'r') } : {}),
|
|
148
|
+
...(rateValue && integerParameter(rateValue, 't') !== undefined ? { resetAfterSeconds: integerParameter(rateValue, 't') } : {}),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
function parseDeprecation(value) {
|
|
152
|
+
const match = value?.match(/^@(\d+)$/);
|
|
153
|
+
if (!match)
|
|
154
|
+
return undefined;
|
|
155
|
+
const milliseconds = Number(match[1]) * 1_000;
|
|
156
|
+
return Number.isSafeInteger(milliseconds) ? new Date(milliseconds).toISOString() : undefined;
|
|
157
|
+
}
|
|
158
|
+
function responseMetadata(response) {
|
|
159
|
+
const retryAfter = parseRetryAfter(response.headers.get(PUBLIC_V1_RESPONSE_HEADERS.retryAfter));
|
|
160
|
+
const deprecatedAt = parseDeprecation(response.headers.get(PUBLIC_V1_RESPONSE_HEADERS.deprecation));
|
|
161
|
+
const sunset = response.headers.get(PUBLIC_V1_RESPONSE_HEADERS.sunset);
|
|
162
|
+
const sunsetAt = sunset && !Number.isNaN(Date.parse(sunset)) ? new Date(sunset).toISOString() : undefined;
|
|
163
|
+
const lifecycle = deprecatedAt || sunsetAt ? { deprecatedAt, sunsetAt } : undefined;
|
|
164
|
+
return {
|
|
165
|
+
requestId: response.headers.get(PUBLIC_V1_RESPONSE_HEADERS.requestId) ?? undefined,
|
|
166
|
+
version: response.headers.get(PUBLIC_V1_RESPONSE_HEADERS.version) ?? undefined,
|
|
167
|
+
operationId: response.headers.get(PUBLIC_V1_RESPONSE_HEADERS.operation) ?? undefined,
|
|
168
|
+
rateLimit: parseRateLimitMetadata(response.headers),
|
|
169
|
+
retry: retryAfter === undefined ? undefined : { afterMilliseconds: retryAfter },
|
|
170
|
+
lifecycle,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
function retryDelay(attempt, retryAfter, options) {
|
|
174
|
+
const exponential = Math.min(options.maxDelayMs, options.baseDelayMs * 2 ** Math.max(0, attempt - 1));
|
|
175
|
+
return Math.max(exponential, parseRetryAfter(retryAfter) ?? 0);
|
|
176
|
+
}
|
|
177
|
+
function waitForRetry(milliseconds, signal, options) {
|
|
178
|
+
return options.sleep(milliseconds, signal)
|
|
179
|
+
.then(() => ok(undefined))
|
|
180
|
+
.catch(cause => err({
|
|
181
|
+
_tag: 'TransportFailure',
|
|
182
|
+
reason: 'aborted',
|
|
183
|
+
message: 'Request aborted while waiting to retry.',
|
|
184
|
+
retryable: false,
|
|
185
|
+
cause,
|
|
186
|
+
}));
|
|
187
|
+
}
|
|
188
|
+
function readJson(response) {
|
|
189
|
+
return response.text()
|
|
190
|
+
.then((text) => {
|
|
191
|
+
if (!text)
|
|
192
|
+
return ok(null);
|
|
193
|
+
return Promise.resolve()
|
|
194
|
+
.then(() => ok(JSON.parse(text)))
|
|
195
|
+
.catch(cause => err([cause]));
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
function contractFailure(operation, response, message, issues) {
|
|
199
|
+
return err({
|
|
200
|
+
_tag: 'ContractFailure',
|
|
201
|
+
operationId: operation.id,
|
|
202
|
+
status: response.status,
|
|
203
|
+
requestId: response.headers.get('x-request-id') ?? undefined,
|
|
204
|
+
message,
|
|
205
|
+
issues,
|
|
206
|
+
metadata: responseMetadata(response),
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
function responseContract(operation, status) {
|
|
210
|
+
return operation.responses[status];
|
|
211
|
+
}
|
|
212
|
+
export function createSdkClient(options) {
|
|
213
|
+
const retry = resolveRetryOptions(options.retry);
|
|
214
|
+
const registered = new Map();
|
|
215
|
+
for (const { operation } of listOperations(options.protocol)) {
|
|
216
|
+
const descriptor = operation;
|
|
217
|
+
registered.set(descriptor.id, descriptor);
|
|
218
|
+
}
|
|
219
|
+
async function execute(operation, input, executeOptions) {
|
|
220
|
+
const known = registered.get(operation.id);
|
|
221
|
+
if (!known || known.method !== operation.method || known.path !== operation.path) {
|
|
222
|
+
return requestFailure(operation, 'input', [`${operation.id} is not registered in this client protocol.`]);
|
|
223
|
+
}
|
|
224
|
+
const resolvedExecuteOptions = (executeOptions ?? {});
|
|
225
|
+
if (operation.policy.idempotency._tag === 'required' && !resolvedExecuteOptions.idempotencyKey) {
|
|
226
|
+
return requestFailure(operation, 'headers', ['Idempotency-Key is required by this operation.']);
|
|
227
|
+
}
|
|
228
|
+
if (operation.policy.idempotency._tag === 'none' && resolvedExecuteOptions.idempotencyKey) {
|
|
229
|
+
return requestFailure(operation, 'headers', ['Idempotency-Key is not accepted by this operation.']);
|
|
230
|
+
}
|
|
231
|
+
const prepared = prepareRequest(operation, input);
|
|
232
|
+
if (prepared._tag === 'Err')
|
|
233
|
+
return prepared;
|
|
234
|
+
const request = makeTransportRequest(operation, prepared.value, resolvedExecuteOptions);
|
|
235
|
+
const canRetry = operation.semantics.retry === 'idempotent';
|
|
236
|
+
for (let attempt = 1; attempt <= retry.maxAttempts; attempt++) {
|
|
237
|
+
if (resolvedExecuteOptions.signal?.aborted) {
|
|
238
|
+
return err({
|
|
239
|
+
_tag: 'TransportFailure',
|
|
240
|
+
reason: 'aborted',
|
|
241
|
+
message: `${operation.id}: request aborted.`,
|
|
242
|
+
retryable: false,
|
|
243
|
+
cause: resolvedExecuteOptions.signal.reason,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
const transported = await options.transport.send(request);
|
|
247
|
+
if (transported._tag === 'Err') {
|
|
248
|
+
if (canRetry && transported.error.retryable && attempt < retry.maxAttempts) {
|
|
249
|
+
const waited = await waitForRetry(retryDelay(attempt, null, retry), resolvedExecuteOptions.signal, retry);
|
|
250
|
+
if (waited._tag === 'Err')
|
|
251
|
+
return waited;
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
return err({ ...transported.error, retryable: canRetry && transported.error.retryable });
|
|
255
|
+
}
|
|
256
|
+
const response = transported.value;
|
|
257
|
+
const payload = await readJson(response);
|
|
258
|
+
if (payload._tag === 'Err')
|
|
259
|
+
return contractFailure(operation, response, `${operation.id}: response was not valid JSON.`, payload.error);
|
|
260
|
+
const success = responseContract(operation, response.status);
|
|
261
|
+
if (success) {
|
|
262
|
+
const parsed = success.client.safeParse(payload.value);
|
|
263
|
+
return parsed.success
|
|
264
|
+
? { _tag: 'Ok', value: parsed.data, metadata: responseMetadata(response) }
|
|
265
|
+
: contractFailure(operation, response, `${operation.id}: response violated its client schema.`, parsed.error.issues);
|
|
266
|
+
}
|
|
267
|
+
if (response.ok)
|
|
268
|
+
return contractFailure(operation, response, `${operation.id}: undeclared success status ${response.status}.`, []);
|
|
269
|
+
const parsedError = operation.errorResponse.client.safeParse(payload.value);
|
|
270
|
+
if (!parsedError.success)
|
|
271
|
+
return contractFailure(operation, response, `${operation.id}: error response violated its schema.`, parsedError.error.issues);
|
|
272
|
+
const envelope = parsedError.data;
|
|
273
|
+
if (!operation.errors.includes(envelope.error.code))
|
|
274
|
+
return contractFailure(operation, response, `${operation.id}: undeclared error code ${envelope.error.code}.`, []);
|
|
275
|
+
if (PUBLIC_V1_ERROR_STATUS[envelope.error.code] !== response.status) {
|
|
276
|
+
return contractFailure(operation, response, `${operation.id}: error code ${envelope.error.code} cannot use status ${response.status}.`, []);
|
|
277
|
+
}
|
|
278
|
+
const failure = {
|
|
279
|
+
_tag: 'ApiFailure',
|
|
280
|
+
operationId: operation.id,
|
|
281
|
+
status: response.status,
|
|
282
|
+
...envelope.error,
|
|
283
|
+
response: envelope,
|
|
284
|
+
metadata: responseMetadata(response),
|
|
285
|
+
};
|
|
286
|
+
if (canRetry && failure.retryable && attempt < retry.maxAttempts) {
|
|
287
|
+
const waited = await waitForRetry(retryDelay(attempt, response.headers.get('retry-after'), retry), resolvedExecuteOptions.signal, retry);
|
|
288
|
+
if (waited._tag === 'Err')
|
|
289
|
+
return waited;
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
return err(failure);
|
|
293
|
+
}
|
|
294
|
+
return err({
|
|
295
|
+
_tag: 'TransportFailure',
|
|
296
|
+
reason: 'network',
|
|
297
|
+
message: `${operation.id}: retry budget exhausted.`,
|
|
298
|
+
retryable: false,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
return { execute };
|
|
302
|
+
}
|
|
303
|
+
export function createDomainClient(client, operations) {
|
|
304
|
+
return Object.fromEntries(Object.entries(operations).map(([key, operation]) => [
|
|
305
|
+
key,
|
|
306
|
+
(input, options) => client.execute(operation, input, options),
|
|
307
|
+
]));
|
|
308
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { DomainClient, RetryOptions, SdkClient } from './client.js';
|
|
2
|
+
import type { PublicApiTransport } from './transport.js';
|
|
3
|
+
import { accountV1Registry, actionsV1Registry, analyticsV1Registry, auditV1Registry, backlinksV1Registry, contentV1Registry, mentionsV1Registry, pagesV1Registry, performanceV1Registry, researchV1Registry, searchV1Registry, sitemapsV1Registry, sitesV1Registry } from '@nuxtseo/protocol/v1';
|
|
4
|
+
export * from './client.js';
|
|
5
|
+
export * from './pairing.js';
|
|
6
|
+
export * from './transport.js';
|
|
7
|
+
export interface CreatePublicV1ClientOptions {
|
|
8
|
+
transport: PublicApiTransport;
|
|
9
|
+
retry?: RetryOptions;
|
|
10
|
+
}
|
|
11
|
+
export interface PublicV1Client extends SdkClient {
|
|
12
|
+
account: DomainClient<typeof accountV1Registry.operations>;
|
|
13
|
+
actions: DomainClient<typeof actionsV1Registry.operations>;
|
|
14
|
+
analytics: DomainClient<typeof analyticsV1Registry.operations>;
|
|
15
|
+
audit: DomainClient<typeof auditV1Registry.operations>;
|
|
16
|
+
backlinks: DomainClient<typeof backlinksV1Registry.operations>;
|
|
17
|
+
content: DomainClient<typeof contentV1Registry.operations>;
|
|
18
|
+
mentions: DomainClient<typeof mentionsV1Registry.operations>;
|
|
19
|
+
pages: DomainClient<typeof pagesV1Registry.operations>;
|
|
20
|
+
performance: DomainClient<typeof performanceV1Registry.operations>;
|
|
21
|
+
research: DomainClient<typeof researchV1Registry.operations>;
|
|
22
|
+
search: DomainClient<typeof searchV1Registry.operations>;
|
|
23
|
+
sites: DomainClient<typeof sitesV1Registry.operations>;
|
|
24
|
+
sitemaps: DomainClient<typeof sitemapsV1Registry.operations>;
|
|
25
|
+
}
|
|
26
|
+
export declare function createPublicV1Client(options: CreatePublicV1ClientOptions): PublicV1Client;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { accountV1Registry, actionsV1Registry, analyticsV1Registry, auditV1Registry, backlinksV1Registry, contentV1Registry, createPublicV1Protocol, mentionsV1Registry, pagesV1Registry, performanceV1Registry, researchV1Registry, searchV1Registry, sitemapsV1Registry, sitesV1Registry } from '@nuxtseo/protocol/v1';
|
|
2
|
+
import { createDomainClient, createSdkClient } from './client.js';
|
|
3
|
+
export * from './client.js';
|
|
4
|
+
export * from './pairing.js';
|
|
5
|
+
export * from './transport.js';
|
|
6
|
+
export function createPublicV1Client(options) {
|
|
7
|
+
const core = createSdkClient({
|
|
8
|
+
protocol: createPublicV1Protocol(),
|
|
9
|
+
transport: options.transport,
|
|
10
|
+
retry: options.retry,
|
|
11
|
+
});
|
|
12
|
+
return {
|
|
13
|
+
execute: core.execute,
|
|
14
|
+
account: createDomainClient(core, accountV1Registry.operations),
|
|
15
|
+
actions: createDomainClient(core, actionsV1Registry.operations),
|
|
16
|
+
analytics: createDomainClient(core, analyticsV1Registry.operations),
|
|
17
|
+
audit: createDomainClient(core, auditV1Registry.operations),
|
|
18
|
+
backlinks: createDomainClient(core, backlinksV1Registry.operations),
|
|
19
|
+
content: createDomainClient(core, contentV1Registry.operations),
|
|
20
|
+
mentions: createDomainClient(core, mentionsV1Registry.operations),
|
|
21
|
+
pages: createDomainClient(core, pagesV1Registry.operations),
|
|
22
|
+
performance: createDomainClient(core, performanceV1Registry.operations),
|
|
23
|
+
research: createDomainClient(core, researchV1Registry.operations),
|
|
24
|
+
search: createDomainClient(core, searchV1Registry.operations),
|
|
25
|
+
sites: createDomainClient(core, sitesV1Registry.operations),
|
|
26
|
+
sitemaps: createDomainClient(core, sitemapsV1Registry.operations),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { CliPairingPollResponse, CliPairingStartRequest, CliPairingStartResponse } from '@nuxtseo/protocol/cli-pairing';
|
|
2
|
+
import type { FetchImplementation, SdkResult } from './transport.js';
|
|
3
|
+
export interface PairingClientOptions {
|
|
4
|
+
baseUrl: string;
|
|
5
|
+
fetch?: FetchImplementation;
|
|
6
|
+
}
|
|
7
|
+
export interface PairingFailure {
|
|
8
|
+
_tag: 'PairingFailure';
|
|
9
|
+
reason: 'aborted' | 'contract' | 'network' | 'status';
|
|
10
|
+
message: string;
|
|
11
|
+
status?: number;
|
|
12
|
+
}
|
|
13
|
+
export interface PairingClient {
|
|
14
|
+
start: (input: CliPairingStartRequest, options?: {
|
|
15
|
+
signal?: AbortSignal;
|
|
16
|
+
}) => Promise<SdkResult<CliPairingStartResponse, PairingFailure>>;
|
|
17
|
+
poll: (input: {
|
|
18
|
+
pairingId: string;
|
|
19
|
+
pollSecret: string;
|
|
20
|
+
}, options?: {
|
|
21
|
+
signal?: AbortSignal;
|
|
22
|
+
}) => Promise<SdkResult<CliPairingPollResponse, PairingFailure>>;
|
|
23
|
+
}
|
|
24
|
+
export type { CliPairingApproved, CliPairingPollResponse, CliPairingStartRequest, CliPairingStartResponse, CliPairingStatus, } from '@nuxtseo/protocol/cli-pairing';
|
|
25
|
+
export { CLI_PAIRING_MAX_POLLS, CLI_PAIRING_POLL_INTERVAL_MS, CLI_PAIRING_TTL_MS, } from '@nuxtseo/protocol/cli-pairing';
|
|
26
|
+
export declare function createPairingClient(options: PairingClientOptions): PairingClient;
|
package/dist/pairing.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Browser pairing client for `nuxtseo login`.
|
|
2
|
+
//
|
|
3
|
+
// This deliberately sits outside `createSdkClient`. That client is driven by the
|
|
4
|
+
// v1 protocol registry and assumes a bearer token on every request; pairing runs
|
|
5
|
+
// before a token exists. The wire format still has one owner: the schemas come
|
|
6
|
+
// from @nuxtseo/protocol/cli-pairing and the server routes validate the same
|
|
7
|
+
// ones.
|
|
8
|
+
import { CLI_PAIRING_POLL_PATH, CLI_PAIRING_START_PATH, cliPairingPollResponseSchema, cliPairingStartResponseSchema, } from '@nuxtseo/protocol/cli-pairing';
|
|
9
|
+
function fail(reason, message, status) {
|
|
10
|
+
return { _tag: 'Err', error: { _tag: 'PairingFailure', reason, message, ...(status === undefined ? {} : { status }) } };
|
|
11
|
+
}
|
|
12
|
+
function resolveUrl(baseUrl, path) {
|
|
13
|
+
return `${baseUrl.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`;
|
|
14
|
+
}
|
|
15
|
+
async function request(options, path, body, parse, signal) {
|
|
16
|
+
const send = options.fetch ?? globalThis.fetch;
|
|
17
|
+
let response;
|
|
18
|
+
try {
|
|
19
|
+
response = await send(resolveUrl(options.baseUrl, path), {
|
|
20
|
+
method: 'POST',
|
|
21
|
+
headers: { 'content-type': 'application/json' },
|
|
22
|
+
body: JSON.stringify(body),
|
|
23
|
+
signal,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
catch (cause) {
|
|
27
|
+
const aborted = cause instanceof Error && (cause.name === 'AbortError' || cause.name === 'TimeoutError');
|
|
28
|
+
return fail(aborted ? 'aborted' : 'network', cause instanceof Error ? cause.message : String(cause));
|
|
29
|
+
}
|
|
30
|
+
if (!response.ok)
|
|
31
|
+
return fail('status', `Pairing request failed with status ${response.status}.`, response.status);
|
|
32
|
+
let payload;
|
|
33
|
+
try {
|
|
34
|
+
payload = await response.json();
|
|
35
|
+
}
|
|
36
|
+
catch (cause) {
|
|
37
|
+
return fail('contract', `Pairing response was not JSON: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
return { _tag: 'Ok', value: parse(payload) };
|
|
41
|
+
}
|
|
42
|
+
catch (cause) {
|
|
43
|
+
return fail('contract', `Pairing response did not match the contract: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export { CLI_PAIRING_MAX_POLLS, CLI_PAIRING_POLL_INTERVAL_MS, CLI_PAIRING_TTL_MS, } from '@nuxtseo/protocol/cli-pairing';
|
|
47
|
+
export function createPairingClient(options) {
|
|
48
|
+
return {
|
|
49
|
+
start: (input, callOptions) => request(options, CLI_PAIRING_START_PATH, input, value => cliPairingStartResponseSchema.parse(value), callOptions?.signal),
|
|
50
|
+
poll: (input, callOptions) => request(options, CLI_PAIRING_POLL_PATH, input, value => cliPairingPollResponseSchema.parse(value), callOptions?.signal),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { PublicV1Method } from '@nuxtseo/protocol/v1/core';
|
|
2
|
+
export type SdkResult<TValue, TError> = {
|
|
3
|
+
_tag: 'Ok';
|
|
4
|
+
value: TValue;
|
|
5
|
+
} | {
|
|
6
|
+
_tag: 'Err';
|
|
7
|
+
error: TError;
|
|
8
|
+
};
|
|
9
|
+
export interface TransportRequest {
|
|
10
|
+
operationId: string;
|
|
11
|
+
method: PublicV1Method;
|
|
12
|
+
path: string;
|
|
13
|
+
headers: Headers;
|
|
14
|
+
body?: string;
|
|
15
|
+
signal?: AbortSignal;
|
|
16
|
+
}
|
|
17
|
+
export interface TransportFailure {
|
|
18
|
+
_tag: 'TransportFailure';
|
|
19
|
+
reason: 'aborted' | 'credential' | 'headers' | 'network';
|
|
20
|
+
message: string;
|
|
21
|
+
retryable: boolean;
|
|
22
|
+
cause?: unknown;
|
|
23
|
+
}
|
|
24
|
+
export interface PublicApiTransport {
|
|
25
|
+
send: (request: TransportRequest) => Promise<SdkResult<Response, TransportFailure>>;
|
|
26
|
+
}
|
|
27
|
+
export type FetchImplementation = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
28
|
+
export type HeadersResolver = HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
|
|
29
|
+
export type TokenResolver = string | (() => string | Promise<string>);
|
|
30
|
+
export interface BrowserTransportOptions {
|
|
31
|
+
baseUrl?: string;
|
|
32
|
+
fetch?: FetchImplementation;
|
|
33
|
+
headers?: HeadersResolver;
|
|
34
|
+
}
|
|
35
|
+
export interface BearerTransportOptions {
|
|
36
|
+
baseUrl: string;
|
|
37
|
+
token: TokenResolver;
|
|
38
|
+
fetch?: FetchImplementation;
|
|
39
|
+
headers?: HeadersResolver;
|
|
40
|
+
}
|
|
41
|
+
export declare function createBrowserTransport(options?: BrowserTransportOptions): PublicApiTransport;
|
|
42
|
+
export declare function createBearerTransport(options: BearerTransportOptions): PublicApiTransport;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
function ok(value) {
|
|
2
|
+
return { _tag: 'Ok', value };
|
|
3
|
+
}
|
|
4
|
+
function err(error) {
|
|
5
|
+
return { _tag: 'Err', error };
|
|
6
|
+
}
|
|
7
|
+
function resolveUrl(baseUrl, path) {
|
|
8
|
+
if (!baseUrl)
|
|
9
|
+
return path;
|
|
10
|
+
return `${baseUrl.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`;
|
|
11
|
+
}
|
|
12
|
+
async function resolveHeaders(resolver) {
|
|
13
|
+
if (!resolver)
|
|
14
|
+
return ok(new Headers());
|
|
15
|
+
return Promise.resolve(typeof resolver === 'function' ? resolver() : resolver)
|
|
16
|
+
.then(value => ok(new Headers(value)))
|
|
17
|
+
.catch(cause => err({
|
|
18
|
+
_tag: 'TransportFailure',
|
|
19
|
+
reason: 'headers',
|
|
20
|
+
message: 'Could not resolve request headers.',
|
|
21
|
+
retryable: false,
|
|
22
|
+
cause,
|
|
23
|
+
}));
|
|
24
|
+
}
|
|
25
|
+
async function resolveToken(resolver) {
|
|
26
|
+
return Promise.resolve(typeof resolver === 'function' ? resolver() : resolver)
|
|
27
|
+
.then((value) => {
|
|
28
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
29
|
+
return err({
|
|
30
|
+
_tag: 'TransportFailure',
|
|
31
|
+
reason: 'credential',
|
|
32
|
+
message: 'The NuxtSEO API token cannot be empty.',
|
|
33
|
+
retryable: false,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return ok(value.trim());
|
|
37
|
+
})
|
|
38
|
+
.catch(cause => err({
|
|
39
|
+
_tag: 'TransportFailure',
|
|
40
|
+
reason: 'credential',
|
|
41
|
+
message: 'Could not resolve the NuxtSEO API token.',
|
|
42
|
+
retryable: false,
|
|
43
|
+
cause,
|
|
44
|
+
}));
|
|
45
|
+
}
|
|
46
|
+
function mergeHeaders(base, request) {
|
|
47
|
+
const headers = new Headers(base);
|
|
48
|
+
request.forEach((value, key) => headers.set(key, value));
|
|
49
|
+
return headers;
|
|
50
|
+
}
|
|
51
|
+
function sendWithFetch(fetchImplementation, baseUrl, credentials, baseHeaders, request) {
|
|
52
|
+
return fetchImplementation(resolveUrl(baseUrl, request.path), {
|
|
53
|
+
method: request.method,
|
|
54
|
+
headers: mergeHeaders(baseHeaders, request.headers),
|
|
55
|
+
body: request.body,
|
|
56
|
+
signal: request.signal,
|
|
57
|
+
credentials,
|
|
58
|
+
})
|
|
59
|
+
.then(ok)
|
|
60
|
+
.catch((cause) => {
|
|
61
|
+
const aborted = request.signal?.aborted === true
|
|
62
|
+
|| (typeof cause === 'object' && cause !== null && 'name' in cause && cause.name === 'AbortError');
|
|
63
|
+
return err({
|
|
64
|
+
_tag: 'TransportFailure',
|
|
65
|
+
reason: aborted ? 'aborted' : 'network',
|
|
66
|
+
message: `${request.operationId}: ${aborted ? 'request aborted' : 'network request failed'}.`,
|
|
67
|
+
retryable: !aborted,
|
|
68
|
+
cause,
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
export function createBrowserTransport(options = {}) {
|
|
73
|
+
const fetchImplementation = options.fetch ?? globalThis.fetch;
|
|
74
|
+
if (typeof fetchImplementation !== 'function')
|
|
75
|
+
throw new TypeError('createBrowserTransport requires a fetch implementation in this runtime.');
|
|
76
|
+
return {
|
|
77
|
+
async send(request) {
|
|
78
|
+
const headers = await resolveHeaders(options.headers);
|
|
79
|
+
if (headers._tag === 'Err')
|
|
80
|
+
return headers;
|
|
81
|
+
headers.value.delete('authorization');
|
|
82
|
+
const requestHeaders = new Headers(request.headers);
|
|
83
|
+
requestHeaders.delete('authorization');
|
|
84
|
+
return sendWithFetch(fetchImplementation, options.baseUrl ?? '', 'include', headers.value, {
|
|
85
|
+
...request,
|
|
86
|
+
headers: requestHeaders,
|
|
87
|
+
});
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
export function createBearerTransport(options) {
|
|
92
|
+
const fetchImplementation = options.fetch ?? globalThis.fetch;
|
|
93
|
+
if (typeof fetchImplementation !== 'function')
|
|
94
|
+
throw new TypeError('createBearerTransport requires a fetch implementation in this runtime.');
|
|
95
|
+
return {
|
|
96
|
+
async send(request) {
|
|
97
|
+
const [headers, token] = await Promise.all([
|
|
98
|
+
resolveHeaders(options.headers),
|
|
99
|
+
resolveToken(options.token),
|
|
100
|
+
]);
|
|
101
|
+
if (headers._tag === 'Err')
|
|
102
|
+
return headers;
|
|
103
|
+
if (token._tag === 'Err')
|
|
104
|
+
return token;
|
|
105
|
+
headers.value.set('authorization', `Bearer ${token.value}`);
|
|
106
|
+
const requestHeaders = new Headers(request.headers);
|
|
107
|
+
requestHeaders.delete('authorization');
|
|
108
|
+
return sendWithFetch(fetchImplementation, options.baseUrl, undefined, headers.value, {
|
|
109
|
+
...request,
|
|
110
|
+
headers: requestHeaders,
|
|
111
|
+
});
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nuxtseo/sdk",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "Descriptor-driven client for the NuxtSEO public HTTP interface.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"homepage": "https://nuxtseo.com/pro/api",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/harlan-zw/nuxtseo.com.git",
|
|
11
|
+
"directory": "packages/sdk"
|
|
12
|
+
},
|
|
13
|
+
"sideEffects": false,
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"import": "./dist/index.js",
|
|
18
|
+
"default": "./dist/index.js"
|
|
19
|
+
},
|
|
20
|
+
"./transport": {
|
|
21
|
+
"types": "./dist/transport.d.ts",
|
|
22
|
+
"import": "./dist/transport.js",
|
|
23
|
+
"default": "./dist/transport.js"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"main": "./dist/index.js",
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"files": [
|
|
29
|
+
"dist"
|
|
30
|
+
],
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=22"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"@nuxtseo/protocol": "^0.1.0"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"typescript": "npm:typescript-native-bridge@6.0.3-bridge.10.tsgo.7.0.2",
|
|
39
|
+
"@nuxtseo/protocol": "0.1.0"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"build": "node scripts/build.mjs",
|
|
46
|
+
"typecheck": "tsc --noEmit"
|
|
47
|
+
}
|
|
48
|
+
}
|