@independo/inderun-web 0.3.0-dev.12 → 0.3.0-dev.14

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,339 @@
1
+ import type {
2
+ ClockService,
3
+ ConnectivityService,
4
+ HostServices,
5
+ HttpClientService,
6
+ HttpRequest,
7
+ HttpResponse,
8
+ HttpStreamResponse,
9
+ HttpStreamingClientService
10
+ } from "./host.js";
11
+
12
+ /**
13
+ * Configuration for the fetch-backed HTTP host service.
14
+ */
15
+ export interface FetchHttpClientOptions {
16
+ /**
17
+ * Optional fetch implementation. Useful for tests, SSR-like environments, or custom wrappers.
18
+ */
19
+ fetchFn?: typeof fetch;
20
+ }
21
+
22
+ /**
23
+ * Browser connectivity service based on `navigator.onLine`.
24
+ */
25
+ export class BrowserConnectivityService implements ConnectivityService {
26
+ /**
27
+ * Returns the browser's current online hint.
28
+ * Non-browser environments are treated as online so injected HTTP clients can still run in tests.
29
+ */
30
+ async isOnline(): Promise<boolean> {
31
+ if (typeof navigator === "undefined") {
32
+ return true;
33
+ }
34
+
35
+ return navigator.onLine;
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Clock service using wall-clock time and the browser performance timer when available.
41
+ */
42
+ export class SystemClockService implements ClockService {
43
+ /**
44
+ * Returns current Unix epoch time in milliseconds.
45
+ */
46
+ now(): number {
47
+ return Date.now();
48
+ }
49
+
50
+ /**
51
+ * Returns monotonic milliseconds from `performance.now()` when available.
52
+ */
53
+ monotonicNow(): number {
54
+ if (typeof performance === "undefined") {
55
+ return Date.now();
56
+ }
57
+
58
+ return performance.now();
59
+ }
60
+ }
61
+
62
+ /**
63
+ * HTTP client service that adapts the platform `fetch` API to IndeRun's normalized HTTP contract.
64
+ */
65
+ export class FetchHttpClient implements HttpClientService {
66
+ private readonly fetchFn: typeof fetch;
67
+
68
+ /**
69
+ * Creates a fetch-backed HTTP client.
70
+ * @param options - Optional fetch implementation override.
71
+ * @throws Error when no fetch implementation is available.
72
+ */
73
+ constructor(options: FetchHttpClientOptions = {}) {
74
+ const fetchFn = options.fetchFn ?? globalThis.fetch?.bind(globalThis);
75
+ if (!fetchFn) {
76
+ throw new Error("FetchHttpClient requires a fetch implementation.");
77
+ }
78
+
79
+ this.fetchFn = fetchFn;
80
+ }
81
+
82
+ /**
83
+ * Sends a normalized HTTP request using `fetch`.
84
+ * @param request - Normalized HTTP request payload.
85
+ * @returns Normalized HTTP response payload with lower-cased response header names.
86
+ */
87
+ async send(request: HttpRequest): Promise<HttpResponse> {
88
+ const controller = typeof AbortController === "undefined" ? undefined : new AbortController();
89
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
90
+
91
+ if (controller && request.timeoutMs !== undefined) {
92
+ timeoutId = setTimeout(() => controller.abort(), request.timeoutMs);
93
+ }
94
+
95
+ try {
96
+ const init: RequestInit = {
97
+ method: request.method
98
+ };
99
+
100
+ if (request.headers) {
101
+ init.headers = request.headers;
102
+ }
103
+ if (request.body !== undefined) {
104
+ init.body = request.body;
105
+ }
106
+ if (controller) {
107
+ init.signal = controller.signal;
108
+ }
109
+
110
+ const response = await this.fetchFn(request.url, init);
111
+
112
+ const headers: Record<string, string> = {};
113
+ response.headers.forEach((value, key) => {
114
+ headers[key.toLowerCase()] = value;
115
+ });
116
+
117
+ return {
118
+ status: response.status,
119
+ statusText: response.statusText,
120
+ headers,
121
+ body: await response.text()
122
+ };
123
+ } finally {
124
+ if (timeoutId !== undefined) {
125
+ clearTimeout(timeoutId);
126
+ }
127
+ }
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Streaming HTTP client service that adapts `fetch` to IndeRun's incremental
133
+ * HTTP contract by handing back `Response.body` rather than buffering it.
134
+ *
135
+ * Two abort sources are merged onto one controller: `timeoutMs`, which bounds
136
+ * only the wait for the response head, and the caller's `signal`, which stays
137
+ * live for the whole body. The timeout timer is therefore cleared as soon as
138
+ * the head resolves — a stream that legitimately stays open for minutes must
139
+ * not be killed by a time-to-first-byte budget.
140
+ */
141
+ export class FetchStreamingHttpClient implements HttpStreamingClientService {
142
+ private readonly fetchFn: typeof fetch;
143
+
144
+ /**
145
+ * Creates a fetch-backed streaming HTTP client.
146
+ * @param options - Optional fetch implementation override.
147
+ * @throws Error when no fetch implementation is available.
148
+ */
149
+ constructor(options: FetchHttpClientOptions = {}) {
150
+ const fetchFn = options.fetchFn ?? globalThis.fetch?.bind(globalThis);
151
+ if (!fetchFn) {
152
+ throw new Error("FetchStreamingHttpClient requires a fetch implementation.");
153
+ }
154
+
155
+ this.fetchFn = fetchFn;
156
+ }
157
+
158
+ /**
159
+ * Sends a normalized HTTP request and resolves once the response head is available.
160
+ * @param request - Normalized HTTP request payload.
161
+ * @param signal - Optional caller-driven cancellation covering the whole response.
162
+ * @returns Response head plus a lazily-consumed body chunk sequence.
163
+ */
164
+ async stream(request: HttpRequest, signal?: AbortSignal): Promise<HttpStreamResponse> {
165
+ const controller = typeof AbortController === "undefined" ? undefined : new AbortController();
166
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
167
+ let onAbort: (() => void) | undefined;
168
+
169
+ if (controller) {
170
+ if (request.timeoutMs !== undefined) {
171
+ timeoutId = setTimeout(() => controller.abort(), request.timeoutMs);
172
+ }
173
+ if (signal) {
174
+ if (signal.aborted) {
175
+ controller.abort();
176
+ } else {
177
+ onAbort = () => controller.abort();
178
+ signal.addEventListener("abort", onAbort, { once: true });
179
+ }
180
+ }
181
+ }
182
+
183
+ const clearTimeoutTimer = () => {
184
+ if (timeoutId !== undefined) {
185
+ clearTimeout(timeoutId);
186
+ timeoutId = undefined;
187
+ }
188
+ };
189
+ const releaseAbortListener = () => {
190
+ if (signal && onAbort) {
191
+ signal.removeEventListener("abort", onAbort);
192
+ onAbort = undefined;
193
+ }
194
+ };
195
+
196
+ let response: Response;
197
+ try {
198
+ const init: RequestInit = { method: request.method };
199
+ if (request.headers) {
200
+ init.headers = request.headers;
201
+ }
202
+ if (request.body !== undefined) {
203
+ init.body = request.body;
204
+ }
205
+ if (controller) {
206
+ init.signal = controller.signal;
207
+ }
208
+
209
+ response = await this.fetchFn(request.url, init);
210
+ } catch (error) {
211
+ clearTimeoutTimer();
212
+ releaseAbortListener();
213
+ throw error;
214
+ }
215
+
216
+ clearTimeoutTimer();
217
+
218
+ const headers: Record<string, string> = {};
219
+ response.headers.forEach((value, key) => {
220
+ headers[key.toLowerCase()] = value;
221
+ });
222
+
223
+ const stream = response.body;
224
+
225
+ async function* iterate(): AsyncGenerator<Uint8Array> {
226
+ try {
227
+ if (!stream) {
228
+ // A bodyless response (204, HEAD) or a fetch polyfill without stream
229
+ // support: fall back to the buffered text so callers still terminate.
230
+ const text = await response.text();
231
+ if (text.length > 0) {
232
+ yield new TextEncoder().encode(text);
233
+ }
234
+ return;
235
+ }
236
+
237
+ const reader = stream.getReader();
238
+ // The body is also raced against `signal` directly, not just against the
239
+ // fetch-level abort: a fetch implementation is free not to error an
240
+ // in-flight body when its request signal fires, and the caller's
241
+ // cancellation guarantee must not depend on that.
242
+ let onBodyAbort: (() => void) | undefined;
243
+ const aborted = new Promise<never>((_resolve, reject) => {
244
+ if (!signal) return;
245
+ onBodyAbort = () =>
246
+ reject(Object.assign(new Error("The stream was aborted."), { name: "AbortError" }));
247
+ if (signal.aborted) {
248
+ onBodyAbort();
249
+ } else {
250
+ signal.addEventListener("abort", onBodyAbort, { once: true });
251
+ }
252
+ });
253
+ // Keeps an abort that arrives after the body finished from surfacing as
254
+ // an unhandled rejection; the race below still observes it.
255
+ void aborted.catch(() => undefined);
256
+
257
+ try {
258
+ for (;;) {
259
+ const { done, value } = await Promise.race([reader.read(), aborted]);
260
+ if (done) return;
261
+ if (value) yield value;
262
+ }
263
+ } catch (error) {
264
+ await reader.cancel().catch(() => undefined);
265
+ throw error;
266
+ } finally {
267
+ if (signal && onBodyAbort) {
268
+ signal.removeEventListener("abort", onBodyAbort);
269
+ }
270
+ reader.releaseLock();
271
+ }
272
+ } finally {
273
+ releaseAbortListener();
274
+ }
275
+ }
276
+
277
+ return {
278
+ status: response.status,
279
+ statusText: response.statusText,
280
+ headers,
281
+ body: iterate()
282
+ };
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Options for constructing default browser host services.
288
+ */
289
+ export interface CreateBrowserHostServicesOptions extends Partial<HostServices> {
290
+ /**
291
+ * Optional fetch implementation used when `httpClient`/`streamingHttpClient`
292
+ * are not supplied.
293
+ */
294
+ fetchFn?: typeof fetch;
295
+ }
296
+
297
+ /**
298
+ * Creates a browser-oriented HostServices bundle for the Web SDK.
299
+ * Supplied host services override the defaults; missing connectivity, clock, and HTTP services are created.
300
+ * @param options - Optional host service overrides and fetch implementation.
301
+ */
302
+ export function createBrowserHostServices(
303
+ options: CreateBrowserHostServicesOptions = {}
304
+ ): HostServices {
305
+ const hostServices: HostServices = {
306
+ connectivity: options.connectivity ?? new BrowserConnectivityService()
307
+ };
308
+
309
+ hostServices.clock = options.clock ?? new SystemClockService();
310
+ if (options.httpClient) {
311
+ hostServices.httpClient = options.httpClient;
312
+ } else if (options.fetchFn) {
313
+ hostServices.httpClient = new FetchHttpClient({ fetchFn: options.fetchFn });
314
+ } else {
315
+ hostServices.httpClient = new FetchHttpClient();
316
+ }
317
+
318
+ if (options.streamingHttpClient) {
319
+ hostServices.streamingHttpClient = options.streamingHttpClient;
320
+ } else if (options.fetchFn) {
321
+ hostServices.streamingHttpClient = new FetchStreamingHttpClient({ fetchFn: options.fetchFn });
322
+ } else {
323
+ hostServices.streamingHttpClient = new FetchStreamingHttpClient();
324
+ }
325
+
326
+ if (options.deviceConstraints) {
327
+ hostServices.deviceConstraints = options.deviceConstraints;
328
+ }
329
+
330
+ if (options.secureStorage) {
331
+ hostServices.secureStorage = options.secureStorage;
332
+ }
333
+
334
+ if (options.telemetry) {
335
+ hostServices.telemetry = options.telemetry;
336
+ }
337
+
338
+ return hostServices;
339
+ }