@outposted/node 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.
@@ -0,0 +1,198 @@
1
+ // ────────────────────────────────────────────────────────────────────────────
2
+ // @outposted/node/transport — HTTP client used by every resource.
3
+ //
4
+ // Responsibilities:
5
+ // - Sign requests (Authorization: Bearer <apiKey>)
6
+ // - Generate a per-request X-Outposted-Request-ID for correlation
7
+ // - Parse 2xx JSON responses
8
+ // - Convert 4xx/5xx into the right OutpostedError subclass
9
+ // - Retry transient failures (5xx, NetworkError) with exponential backoff
10
+ //
11
+ // Retry policy mirrors what we tell users in docs:
12
+ // - 3 attempts total (initial + 2 retries)
13
+ // - Backoff: 250ms, 500ms, 1000ms
14
+ // - NEVER retry on 4xx (caller bug — retry won't fix it)
15
+ // - ALWAYS retry on network errors and 5xx
16
+ //
17
+ // Fetch is injected so consumers can polyfill (Node <18) or stub in tests.
18
+ // ────────────────────────────────────────────────────────────────────────────
19
+
20
+ import { OutpostedError, OutpostedNetworkError, type OutpostedErrorBody } from './errors';
21
+
22
+ export type FetchLike = typeof globalThis.fetch;
23
+
24
+ export interface HttpClientOptions {
25
+ apiKey: string;
26
+ baseUrl?: string;
27
+ fetch?: FetchLike;
28
+ }
29
+
30
+ export interface RequestOptions {
31
+ method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
32
+ path: string;
33
+ body?: unknown;
34
+ query?: Record<string, string | number | boolean | undefined | null>;
35
+ }
36
+
37
+ const DEFAULT_BASE_URL = 'https://api.outposted.one';
38
+ const MAX_ATTEMPTS = 3;
39
+ const BACKOFFS_MS = [250, 500, 1000];
40
+
41
+ export class HttpClient {
42
+ private readonly apiKey: string;
43
+ /**
44
+ * Resolved base URL (no trailing slash). Exposed so resources that build
45
+ * non-HTTP URLs (e.g. OAuth start redirects) can reuse the same origin the
46
+ * client was configured with.
47
+ */
48
+ public readonly baseUrl: string;
49
+ private readonly fetchImpl: FetchLike;
50
+
51
+ constructor(options: HttpClientOptions) {
52
+ this.apiKey = options.apiKey;
53
+ this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
54
+ const fetchImpl = options.fetch ?? globalThis.fetch;
55
+ if (!fetchImpl) {
56
+ throw new Error(
57
+ 'HttpClient: fetch is not available. Provide options.fetch or run on Node >=18.',
58
+ );
59
+ }
60
+ this.fetchImpl = fetchImpl.bind(globalThis);
61
+ }
62
+
63
+ async request<T>(options: RequestOptions): Promise<T> {
64
+ const url = this.buildUrl(options.path, options.query);
65
+ const hasBody = options.body !== undefined;
66
+ const headers: Record<string, string> = {
67
+ Authorization: `Bearer ${this.apiKey}`,
68
+ Accept: 'application/json',
69
+ 'X-Outposted-Request-ID': generateRequestId(),
70
+ };
71
+ if (hasBody) {
72
+ headers['Content-Type'] = 'application/json';
73
+ }
74
+
75
+ const init: RequestInit = {
76
+ method: options.method,
77
+ headers,
78
+ body: hasBody ? JSON.stringify(options.body) : undefined,
79
+ };
80
+
81
+ let lastError: unknown;
82
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
83
+ try {
84
+ const response = await this.fetchImpl(url, init);
85
+ const responseHeaders = readHeaders(response);
86
+ const requestId = responseHeaders['x-outposted-request-id'] ?? headers['X-Outposted-Request-ID'];
87
+
88
+ if (response.status >= 200 && response.status < 300) {
89
+ return await parseJsonOrEmpty<T>(response);
90
+ }
91
+
92
+ const body = await parseErrorBody(response);
93
+ const error = OutpostedError.fromResponse(response.status, body, responseHeaders, requestId);
94
+
95
+ if (response.status >= 500 && attempt < MAX_ATTEMPTS - 1) {
96
+ lastError = error;
97
+ await sleep(getBackoff(attempt));
98
+ continue;
99
+ }
100
+ throw error;
101
+ } catch (err) {
102
+ if (err instanceof OutpostedError && (err.status === undefined || err.status < 500)) {
103
+ // 4xx — never retry.
104
+ throw err;
105
+ }
106
+ if (err instanceof OutpostedError) {
107
+ // 5xx already handled in the 5xx branch above. Reaching here means
108
+ // we exhausted retries — rethrow.
109
+ throw err;
110
+ }
111
+ // Network-layer failure.
112
+ const networkError = wrapNetworkError(err);
113
+ if (attempt < MAX_ATTEMPTS - 1) {
114
+ lastError = networkError;
115
+ await sleep(getBackoff(attempt));
116
+ continue;
117
+ }
118
+ throw networkError;
119
+ }
120
+ }
121
+
122
+ // Should be unreachable — the loop always throws or returns.
123
+ throw lastError instanceof Error
124
+ ? lastError
125
+ : new OutpostedNetworkError('Outposted SDK: exhausted retries without a definitive error');
126
+ }
127
+
128
+ private buildUrl(path: string, query?: RequestOptions['query']): string {
129
+ const normalizedPath = path.startsWith('/') ? path : `/${path}`;
130
+ const url = new URL(`${this.baseUrl}${normalizedPath}`);
131
+ if (query) {
132
+ for (const [key, value] of Object.entries(query)) {
133
+ if (value === undefined || value === null) continue;
134
+ url.searchParams.set(key, String(value));
135
+ }
136
+ }
137
+ return url.toString();
138
+ }
139
+ }
140
+
141
+ // ── helpers ────────────────────────────────────────────────────────────────
142
+
143
+ function generateRequestId(): string {
144
+ const cryptoObj = globalThis.crypto;
145
+ if (cryptoObj && typeof cryptoObj.randomUUID === 'function') {
146
+ return cryptoObj.randomUUID();
147
+ }
148
+ // Fallback — shouldn't be hit on Node 20+ or any modern runtime.
149
+ return `req_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
150
+ }
151
+
152
+ function readHeaders(response: Response): Record<string, string> {
153
+ const out: Record<string, string> = {};
154
+ response.headers.forEach((value, key) => {
155
+ out[key.toLowerCase()] = value;
156
+ });
157
+ return out;
158
+ }
159
+
160
+ async function parseJsonOrEmpty<T>(response: Response): Promise<T> {
161
+ const text = await response.text();
162
+ if (!text) {
163
+ return undefined as T;
164
+ }
165
+ try {
166
+ return JSON.parse(text) as T;
167
+ } catch {
168
+ return undefined as T;
169
+ }
170
+ }
171
+
172
+ async function parseErrorBody(response: Response): Promise<OutpostedErrorBody | null> {
173
+ const text = await response.text().catch(() => '');
174
+ if (!text) return null;
175
+ try {
176
+ const parsed: unknown = JSON.parse(text);
177
+ if (typeof parsed === 'object' && parsed !== null) {
178
+ return parsed as OutpostedErrorBody;
179
+ }
180
+ return { message: String(parsed) };
181
+ } catch {
182
+ return { message: text };
183
+ }
184
+ }
185
+
186
+ function wrapNetworkError(err: unknown): OutpostedNetworkError {
187
+ const message =
188
+ err instanceof Error ? `Outposted SDK: network error — ${err.message}` : 'Outposted SDK: network error';
189
+ return new OutpostedNetworkError(message, err);
190
+ }
191
+
192
+ function getBackoff(attempt: number): number {
193
+ return BACKOFFS_MS[attempt] ?? BACKOFFS_MS[BACKOFFS_MS.length - 1] ?? 1000;
194
+ }
195
+
196
+ function sleep(ms: number): Promise<void> {
197
+ return new Promise((resolve) => setTimeout(resolve, ms));
198
+ }